The relay refuses a blocked sender only on an identified send. A sealed send is authorised by the recipient's delivery key and does not say who sent it, so a blocked contact who already holds that key keeps getting through. Revoking delivery tokens does not close it: a refused token falls back to the delivery key by design. The recipient learns the sender when it unseals, so the recipient has to refuse. Found on the iPhone-to-Android pair: after the iPhone blocked the emulator, the emulator's next message went sealed, the relay accepted it (blocks row 7 -> 6 present) and the iPhone showed it. This app had no such check either. The blocked list is kept in the vault, written on every block and unblock and whenever the relay's list is fetched, and absorb() drops messages and call signals from anyone on it. ABlockedSenderStaysOutTest: 2 red before, 4 green after; engine Vault tests 75 green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
138 lines
4.7 KiB
Kotlin
138 lines
4.7 KiB
Kotlin
package io.flamenet.messenger.app
|
|
|
|
import io.flamenet.client.Conversations
|
|
import io.flamenet.client.MessengerApi
|
|
import io.flamenet.client.RelayClient
|
|
import io.flamenet.e2e.Content
|
|
import io.flamenet.e2e.DeviceIdentity
|
|
import io.flamenet.e2e.Vault
|
|
import okhttp3.mockwebserver.MockWebServer
|
|
import org.junit.runner.RunWith
|
|
import org.robolectric.RobolectricTestRunner
|
|
import org.robolectric.annotation.Config
|
|
import java.io.File
|
|
import javax.crypto.KeyGenerator
|
|
import javax.crypto.SecretKey
|
|
import kotlin.test.AfterTest
|
|
import kotlin.test.BeforeTest
|
|
import kotlin.test.Test
|
|
import kotlin.test.assertEquals
|
|
import kotlin.test.assertTrue
|
|
|
|
/**
|
|
* A blocked contact's messages do not reach the thread, sealed or not.
|
|
*
|
|
* Found on the iPhone-to-Android pair, 2026-10-08. The relay enforces a block
|
|
* only on an IDENTIFIED send (Routes.swift, `Block.blocks`). A sealed send is
|
|
* authorised by the recipient's delivery key and the relay does not know who
|
|
* sent it, so a blocked contact who already holds that key keeps getting
|
|
* through: the emulator sent sealed after the iPhone blocked it, the relay
|
|
* accepted it, and the iPhone showed it. Revoking delivery tokens does not
|
|
* help, a refused token falls back to the delivery key by design. The one
|
|
* place that knows the sender is the recipient, after it unseals, so the
|
|
* recipient has to refuse. This side did not either.
|
|
*/
|
|
@RunWith(RobolectricTestRunner::class)
|
|
@Config(sdk = [34])
|
|
class ABlockedSenderStaysOutTest {
|
|
private lateinit var server: MockWebServer
|
|
|
|
@BeforeTest fun start() {
|
|
server = MockWebServer().also { it.start() }
|
|
}
|
|
|
|
@AfterTest fun stop() {
|
|
server.shutdown()
|
|
}
|
|
|
|
private class MapStorage : Vault.Storage {
|
|
private val records = mutableMapOf<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 val me = 1L
|
|
private val blocked = 2L
|
|
private val friend = 3L
|
|
|
|
private fun session(storage: Vault.Storage = MapStorage(), key: SecretKey = key()): Session {
|
|
val identity = DeviceIdentity.create()
|
|
val vault = Vault(storage, key).also { it.saveIdentity(identity) }
|
|
val base = server.url("/").toString()
|
|
return Session(
|
|
"alice",
|
|
me,
|
|
identity,
|
|
vault,
|
|
RelayClient(base, "alice", "app pass word"),
|
|
MessengerApi(base, "alice", "app pass word"),
|
|
)
|
|
}
|
|
|
|
private fun from(
|
|
user: Long,
|
|
text: String,
|
|
sealed: Boolean = true,
|
|
) = Conversations.Received(
|
|
id = user * 100,
|
|
fromUser = user,
|
|
fromDevice = "phone-$user",
|
|
content = Content.parse(Content.text(text, messageId = "m-$user"))!!,
|
|
sealed = sealed,
|
|
)
|
|
|
|
@Test
|
|
fun `a sealed message from a blocked contact is not filed`() {
|
|
val s = session()
|
|
s.vault.saveBlockedSenders(setOf(blocked))
|
|
|
|
val touched = s.absorb(listOf(from(blocked, "you blocked me and here I am"), from(friend, "hello")))
|
|
|
|
assertTrue(s.history(Vault.threadForUser(blocked)).isEmpty(), "a blocked contact's message reached the thread")
|
|
assertEquals(setOf(Vault.threadForUser(friend)), touched, "the block also stopped somebody it does not name")
|
|
}
|
|
|
|
/** Unblocking lets them back in. */
|
|
@Test
|
|
fun `unblocking lets them back in`() {
|
|
val s = session()
|
|
s.vault.saveBlockedSenders(setOf(blocked))
|
|
s.vault.saveBlockedSenders(emptySet())
|
|
|
|
s.absorb(listOf(from(blocked, "back again")))
|
|
assertEquals(1, s.history(Vault.threadForUser(blocked)).size)
|
|
}
|
|
|
|
/** It has to hold before the first fetch from the relay after a launch. */
|
|
@Test
|
|
fun `the block survives a restart`() {
|
|
val storage = MapStorage()
|
|
val k = key()
|
|
session(storage, k).vault.saveBlockedSenders(setOf(blocked))
|
|
assertEquals(setOf(blocked), session(storage, k).vault.blockedSenders())
|
|
}
|
|
|
|
/** Blocking and the relay's list both feed it. */
|
|
@Test
|
|
fun `blocking records the sender locally`() {
|
|
val vm = File("src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt").readText()
|
|
val body = vm.substringAfter("fun setBlocked(").substringBefore("\n fun ")
|
|
assertTrue(body.contains("saveBlockedSenders("), "setBlocked never tells this device who is blocked")
|
|
}
|
|
}
|