🔴 **The obvious implementation is the one that must not be built.** Having each recipient fetch the URL to draw a card tells the site the reader's IP address, that they received a link, and roughly when they opened it — once per member on a group message. A messenger that hides the message and then has every reader announce its contents to a third party has given away what it was protecting. So the SENDER fetches, and the preview travels inside the ciphertext. The receiver renders from those bytes and contacts nobody. ⚠️ Sender-side is a smaller leak, not no leak: the site learns somebody is about to share that URL, from this address. The setting exists for that, and the copy says who learns what rather than implying it is free. 🔴 **The host is shown separately and derived from the URL**, because title and description come from a page the sender did not write — a card claiming one destination while the link points at another is a phishing primitive. Both fields are bounded on the way IN as well as line-limited at render, so the cap does not depend on a future renderer remembering one. ⚠️ `Content.text` still returns the raw body byte-for-byte when there is no preview, so an ordinary message is unchanged on the wire and older clients see exactly what they saw before. ⚠️ The fetch is short-timeout and best-effort, and runs on the IO context. A preview is a decoration and must never be able to delay a send — every failure path ends as a message with a plain URL in it. engine 168, client 96, app 40. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
354 lines
14 KiB
Kotlin
354 lines
14 KiB
Kotlin
package io.flamenet.e2e
|
|
|
|
import kotlin.test.Test
|
|
import kotlin.test.assertContentEquals
|
|
import kotlin.test.assertEquals
|
|
import kotlin.test.assertFailsWith
|
|
import kotlin.test.assertFalse
|
|
import kotlin.test.assertNull
|
|
import kotlin.test.assertTrue
|
|
|
|
class ContentTest {
|
|
|
|
private fun ref(kind: String = Attachment.IMAGE) = Attachment.Ref(
|
|
kind = kind,
|
|
id = "blob-1",
|
|
key = ByteArray(32) { it.toByte() },
|
|
digest = ByteArray(32) { (it * 3).toByte() },
|
|
bytes = 1234,
|
|
mime = "image/jpeg",
|
|
width = 800,
|
|
height = 600,
|
|
)
|
|
|
|
/**
|
|
* ⚠️ The marker is a **prefix test on the bytes**, so `fnc` must be
|
|
* serialised first. A JSON library that sorts keys puts `att` first, every
|
|
* envelope it produces reads as plain text on the far side, and somebody's
|
|
* chat fills with raw JSON.
|
|
*/
|
|
@Test
|
|
fun `every envelope starts with the marker`() {
|
|
val envelopes = listOf(
|
|
Content.text("hi", messageId = "m1"),
|
|
Content.groupText("hi", groupId = "g1"),
|
|
Content.attachment(ref(), caption = "look"),
|
|
Content.reaction("👍", Content.Reference("m1", 7, "hi")),
|
|
Content.callSignal(Content.CallSignal(Content.CallSignal.OFFER, "c1", sdp = "v=0")),
|
|
)
|
|
envelopes.forEach { assertTrue(it.startsWith("{\"fnc\":1,"), "bad prefix: $it") }
|
|
}
|
|
|
|
/**
|
|
* ⚠️ Plain 1:1 text stays raw UTF-8. A client predating content envelopes
|
|
* shows an envelope as literal JSON, so the simplest message keeps the
|
|
* format every client has always understood.
|
|
*/
|
|
@Test
|
|
fun `plain text is not wrapped at all`() {
|
|
assertEquals("just a message", Content.text("just a message"))
|
|
assertEquals("just a message", Content.parse("just a message").text)
|
|
}
|
|
|
|
@Test
|
|
fun `a message whose text happens to be JSON is still text`() {
|
|
val tricky = """{"kind":"react","caption":"nope"}"""
|
|
val parsed = Content.parse(tricky)
|
|
assertEquals(tricky, parsed.text)
|
|
assertNull(parsed.reaction)
|
|
}
|
|
|
|
/** Not an envelope, because `fnc` is not 1. Must not be reinterpreted. */
|
|
@Test
|
|
fun `an envelope from a future version reads as text rather than vanishing`() {
|
|
val future = """{"fnc":9,"kind":"hologram","caption":"hello"}"""
|
|
assertEquals(future, Content.parse(future).text)
|
|
}
|
|
|
|
@Test
|
|
fun `an attachment survives the round trip`() {
|
|
val encoded = Content.attachment(ref(), caption = "a caption")
|
|
val parsed = Content.parse(encoded)
|
|
assertEquals("a caption", parsed.text)
|
|
assertEquals(Attachment.IMAGE, parsed.kind)
|
|
val got = parsed.attachment!!
|
|
assertEquals("blob-1", got.id)
|
|
assertEquals(800, got.width)
|
|
assertEquals("image/jpeg", got.mime)
|
|
assertContentEquals(ref().key, got.key)
|
|
assertContentEquals(ref().digest, got.digest)
|
|
assertTrue(parsed.isRenderable)
|
|
}
|
|
|
|
@Test
|
|
fun `a reaction never becomes a message`() {
|
|
val about = Content.Reference("m1", 7, "the original")
|
|
val parsed = Content.parse(Content.reaction("🔥", about))
|
|
assertEquals("🔥", parsed.reaction)
|
|
assertEquals("", parsed.text)
|
|
assertEquals("m1", parsed.about?.messageId)
|
|
assertFalse(parsed.isRenderable)
|
|
|
|
// An empty emoji takes one back — a message, not an absence, because
|
|
// the other device has already rendered the first one.
|
|
assertEquals("", Content.parse(Content.reaction("", about)).reaction)
|
|
}
|
|
|
|
/**
|
|
* ⚠️ The web client sends `delivery_key`, `kt_head` and `sent` down this
|
|
* same channel. A client that renders every envelope it does not recognise
|
|
* shows those to the user as a bubble full of JSON.
|
|
*/
|
|
@Test
|
|
fun `control messages are consumed rather than shown`() {
|
|
for (kind in listOf("delivery_key", "kt_head", "sent")) {
|
|
val parsed = Content.parse("""{"fnc":1,"kind":"$kind","key":"abc","to":3}""")
|
|
assertEquals(kind, parsed.kind)
|
|
assertFalse(parsed.isRenderable, "$kind would have been rendered")
|
|
assertEquals("", parsed.text)
|
|
// The envelope survives so a caller can act on it.
|
|
assertEquals("abc", parsed.raw?.optString("key"))
|
|
}
|
|
}
|
|
|
|
@Test
|
|
fun `call signals are not messages`() {
|
|
val encoded = Content.callSignal(
|
|
Content.CallSignal(Content.CallSignal.OFFER, "call-9", sdp = "v=0\r\n"))
|
|
val parsed = Content.parse(encoded)
|
|
assertEquals("call-9", parsed.call?.callId)
|
|
assertEquals("v=0\r\n", parsed.call?.sdp)
|
|
assertFalse(parsed.isRenderable)
|
|
}
|
|
|
|
@Test
|
|
fun `a reply carries its own preview`() {
|
|
val about = Content.Reference("m7", 42, "what was said before")
|
|
val parsed = Content.parse(Content.text("answering", messageId = "m8", about = about))
|
|
assertEquals("answering", parsed.text)
|
|
assertEquals("what was said before", parsed.about?.preview)
|
|
assertEquals(42, parsed.about?.author)
|
|
}
|
|
|
|
@Test
|
|
fun `group routing survives`() {
|
|
val parsed = Content.parse(Content.groupText("to everyone", groupId = "g-99"))
|
|
assertEquals("g-99", parsed.groupId)
|
|
assertEquals("to everyone", parsed.text)
|
|
}
|
|
|
|
/**
|
|
* 🔴 Forward compatibility for control traffic.
|
|
*
|
|
* Control messages ride the same channel as chat and are recognised by
|
|
* NAME — an allowlist of kinds this build knows. A control kind from a
|
|
* newer peer is in no such set, so it fell through and became a
|
|
* `[unsupported attachment]` bubble: a message the user can neither read
|
|
* nor act on, for something never meant for them to see.
|
|
*
|
|
* `ctl: 1` is what lets every kind added from here on stay quiet.
|
|
*/
|
|
@Test
|
|
fun `a flagged unknown control kind is consumed, not rendered`() {
|
|
val parsed = Content.parse("""{"fnc":1,"kind":"something-new","ctl":true}""")
|
|
assertTrue(parsed.isControl, "a ctl-flagged envelope is control traffic")
|
|
assertFalse(parsed.isRenderable, "and must never reach the thread")
|
|
}
|
|
|
|
/**
|
|
* ⚠️ The opposite has to stay true. Unknown *content* is still shown, with
|
|
* a placeholder — silently dropping a real message is the one failure a
|
|
* messenger cannot make, so the flag is what decides, not the unknown-ness.
|
|
*/
|
|
@Test
|
|
fun `an unknown kind without the flag is still shown`() {
|
|
val parsed = Content.parse("""{"fnc":1,"kind":"hologram"}""")
|
|
assertTrue(parsed.needsNewerClient)
|
|
assertFalse(parsed.isControl)
|
|
assertTrue(parsed.isRenderable)
|
|
}
|
|
|
|
/** Kinds that predate the flag keep working without carrying it. */
|
|
@Test
|
|
fun `known control kinds are still control without the flag`() {
|
|
for (kind in listOf("delivery_key", "kt_head", "sent", "avatar")) {
|
|
val parsed = Content.parse("""{"fnc":1,"kind":"$kind"}""")
|
|
assertTrue(parsed.isControl, "$kind must stay control traffic")
|
|
assertFalse(parsed.isRenderable, "$kind must not render")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Disappearing-message timers (§6.11).
|
|
*
|
|
* 🔴 The timer travels **inside the ratchet**. Sent any other way the relay
|
|
* would learn that a conversation is timed and for how long — metadata this
|
|
* feature exists to avoid producing, on a product whose claim is that the
|
|
* server knows as little as possible.
|
|
*/
|
|
@Test
|
|
fun `a disappearing timer round-trips and is control traffic`() {
|
|
val parsed = Content.parse(Content.disappearing(86_400))
|
|
assertEquals(Content.Kind.DISAPPEAR, parsed.kind)
|
|
assertEquals(86_400, Content.disappearSeconds(parsed))
|
|
assertTrue(parsed.isControl, "the timer is never shown as a message")
|
|
assertFalse(parsed.isRenderable)
|
|
}
|
|
|
|
/** Zero is how the timer is turned off, and must survive as zero. */
|
|
@Test
|
|
fun `turning the timer off round-trips as zero`() {
|
|
val parsed = Content.parse(Content.disappearing(0))
|
|
assertEquals(0, Content.disappearSeconds(parsed))
|
|
}
|
|
|
|
/**
|
|
* ⚠️ `fnc` must stay the first key. The marker is a prefix test, so a
|
|
* reordered top level makes the envelope decode as a plain text message —
|
|
* raw JSON in somebody's conversation.
|
|
*/
|
|
@Test
|
|
fun `the timer envelope starts with the marker`() {
|
|
assertTrue(Content.disappearing(60).startsWith("""{"fnc":1,"""))
|
|
}
|
|
|
|
@Test
|
|
fun `asking a non-timer envelope for its timer gives nothing`() {
|
|
assertNull(Content.disappearSeconds(Content.parse(Content.text("hello"))))
|
|
}
|
|
|
|
/**
|
|
* Link previews (§6.12).
|
|
*
|
|
* 🔴 The SENDER generates these and they ride inside the ciphertext. A card
|
|
* each recipient fetched would tell the site the reader's IP, that they
|
|
* received a link, and roughly when they opened it — once per member on a
|
|
* group message.
|
|
*/
|
|
@Test
|
|
fun `a link preview round-trips`() {
|
|
val preview = Content.LinkPreview.of("https://example.com/x", "A title", "A desc")
|
|
val parsed = Content.parse(Content.text("look https://example.com/x", link = preview))
|
|
assertEquals("https://example.com/x", parsed.link?.url)
|
|
assertEquals("A title", parsed.link?.title)
|
|
assertEquals("look https://example.com/x", parsed.text)
|
|
}
|
|
|
|
/**
|
|
* 🔴 The host comes from the URL, never the page. A card whose title claims
|
|
* one destination while the link points at another is a phishing primitive.
|
|
*/
|
|
@Test
|
|
fun `the host comes from the url not the title`() {
|
|
val preview = Content.LinkPreview.of(
|
|
"https://evil.example/login", "Sign in — yourbank.com", "")
|
|
assertEquals("evil.example", preview.host)
|
|
}
|
|
|
|
/** ⚠️ Bounded on the way in, so no renderer has to remember a cap. */
|
|
@Test
|
|
fun `title and description are bounded`() {
|
|
val preview = Content.LinkPreview.of(
|
|
"https://example.com", "A".repeat(5_000), "B".repeat(5_000))
|
|
assertEquals(Content.LinkPreview.MAX_TITLE, preview.title.length)
|
|
assertEquals(Content.LinkPreview.MAX_DESC, preview.desc.length)
|
|
}
|
|
|
|
/** A preview with no title is not a preview — it would render as a blank card. */
|
|
@Test
|
|
fun `a preview without a title is refused`() {
|
|
val json = org.json.JSONObject().put("url", "https://example.com").put("title", "")
|
|
assertNull(Content.LinkPreview.fromJson(json))
|
|
}
|
|
|
|
/** Optional: a plain message must be byte-identical to before. */
|
|
@Test
|
|
fun `a message without a preview stays raw text`() {
|
|
assertEquals("plain", Content.text("plain"))
|
|
assertNull(Content.parse("plain").link)
|
|
}
|
|
|
|
@Test
|
|
fun `an unknown attachment kind shows a label rather than disappearing`() {
|
|
val parsed = Content.parse("""{"fnc":1,"kind":"hologram"}""")
|
|
assertEquals("[unsupported attachment]", parsed.text)
|
|
assertTrue(parsed.isRenderable)
|
|
}
|
|
|
|
@Test
|
|
fun `a malformed key in a reference is refused, not used`() {
|
|
val short = """{"fnc":1,"kind":"image","att":{"id":"x","key":"AAAA","digest":"AAAA"}}"""
|
|
val parsed = Content.parse(short)
|
|
assertNull(parsed.attachment)
|
|
assertEquals("[unsupported attachment]", parsed.text)
|
|
}
|
|
|
|
@Test
|
|
fun `captions with quotes and newlines survive`() {
|
|
val awkward = "she said \"hello\"\nand \\ left"
|
|
assertEquals(awkward, Content.parse(Content.groupText(awkward, "g1")).text)
|
|
}
|
|
|
|
// -- Blobs ---------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `a blob round-trips`() {
|
|
val data = ByteArray(5000) { (it % 251).toByte() }
|
|
val sealed = Attachment.seal(data)
|
|
val reference = ref().copy(key = sealed.key, digest = sealed.digest)
|
|
assertContentEquals(data, Attachment.open(sealed.blob, reference))
|
|
}
|
|
|
|
@Test
|
|
fun `two attachments never share a key`() {
|
|
val a = Attachment.seal(ByteArray(16))
|
|
val b = Attachment.seal(ByteArray(16))
|
|
assertFalse(a.key.contentEquals(b.key))
|
|
}
|
|
|
|
/**
|
|
* ⚠️ The digest is checked BEFORE decrypting. AES-GCM would catch this too,
|
|
* but failing on the digest names the problem — "the relay served different
|
|
* bytes" — instead of reporting it as a decryption failure indistinguishable
|
|
* from a broken session.
|
|
*/
|
|
@Test
|
|
fun `a substituted blob is refused by digest, not by the cipher`() {
|
|
val sealed = Attachment.seal("secret".toByteArray())
|
|
val bent = sealed.blob.copyOf().also { it[it.size / 2] = (it[it.size / 2] + 1).toByte() }
|
|
val failure = assertFailsWith<SecurityException> {
|
|
Attachment.open(bent, ref().copy(key = sealed.key, digest = sealed.digest))
|
|
}
|
|
assertTrue(failure.message!!.contains("digest"))
|
|
}
|
|
|
|
@Test
|
|
fun `the wrong key does not open a blob`() {
|
|
val sealed = Attachment.seal("secret".toByteArray())
|
|
assertFailsWith<Aead.DecryptionFailed> {
|
|
Attachment.open(sealed.blob, ref().copy(key = ByteArray(32), digest = sealed.digest))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 🔴 The vault persists refs through toJson and reads "exp" back out of
|
|
* that very JSON — a field toJson never wrote reloaded as 0, which both
|
|
* rendered every story as "no expiry" and (because 0 means immortal to
|
|
* isActive) meant expired story bytes were never dropped on write.
|
|
*/
|
|
@Test
|
|
fun `a story ref keeps its expiry through the json round trip`() {
|
|
val posted = ref(kind = Attachment.STORY).copy(expires = 1_700_000_000.5)
|
|
val json = posted.toJson()
|
|
val reloaded = Attachment.Ref.fromJson(
|
|
Attachment.STORY, json, json.optDouble("exp", 0.0))!!
|
|
kotlin.test.assertEquals(posted.expires, reloaded.expires)
|
|
}
|
|
|
|
/** Non-story refs never grow an "exp" field they did not have before. */
|
|
@Test
|
|
fun `an image ref does not gain an expiry field`() {
|
|
assertFalse(ref().toJson().has("exp"))
|
|
}
|
|
}
|