Embedded live streams in stories, behind a disclosure (§6.13)
Every other media path here is a sealed blob — the relay stores ciphertext and nobody else is involved. A live stream is not that: it is continuous and hosted elsewhere, so there is nothing to seal, and watching one means this device connects to that host, which learns the viewer's IP address and that they watched. 🔴 **§6.12's answer does not transfer.** A link preview works because the sender fetches it once and puts the result inside the ciphertext. A stream cannot be fetched once. So this is disclosure rather than prevention. The URL rides inside the ciphertext, so the relay learns nothing about what is watched or by whom. Playback is an explicit tap with the host named and what it learns spelled out — a card that started playing on its own would make that disclosure after the fact, which is not a disclosure. ⚠️ The attachment is the POSTER, not the stream. Keeping a real blob means the strip, the unseen rings and the expiry sweep all work unchanged, and the viewer sees a still before deciding whether to connect. ⚠️ The host is derived from the URL, never the caption — a story captioned "youtube.com" pointing elsewhere is the phishing shape link previews already guard against. Non-http(s) URLs and ones with no host are refused: a `file:` URL arriving from another person is not something to hand a media player, and a destination nobody can name is one nobody can consent to. The wording lives in the engine rather than the UI so it can be asserted. engine 177, client 96, app 46. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b8ffe2ea71
commit
1bbf8b460b
@@ -0,0 +1,103 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Embedded live streams in stories (spec §6.13).
|
||||
*
|
||||
* 🔴 **The one media path where the viewer must contact a third party.** Every
|
||||
* other one is a sealed blob; a live stream is continuous and hosted elsewhere,
|
||||
* so watching it means this device connects to that host, which learns the
|
||||
* viewer's address and that they watched. §6.12's answer — have the sender
|
||||
* fetch it once — does not transfer, because a stream cannot be fetched once.
|
||||
*
|
||||
* Everything here exists to make that a decision rather than a surprise.
|
||||
*/
|
||||
class StreamDisclosureTest {
|
||||
|
||||
@Test
|
||||
fun `the disclosure names the host`() {
|
||||
assertTrue("live.example.com" in StreamDisclosure.text("live.example.com"))
|
||||
}
|
||||
|
||||
/** ⚠️ Must say what the host learns, not merely that something connects. */
|
||||
@Test
|
||||
fun `the disclosure says what the host learns`() {
|
||||
val text = StreamDisclosure.text("live.example.com")
|
||||
assertTrue("IP address" in text, text)
|
||||
assertTrue("watched" in text, text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `web streams are acceptable`() {
|
||||
assertTrue(StreamDisclosure.acceptable("https://live.example.com/s.m3u8"))
|
||||
assertTrue(StreamDisclosure.acceptable("http://live.example.com/s.m3u8"))
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 A `file:` URL arriving from another person is not something to hand a
|
||||
* media player — playing it would read this device.
|
||||
*/
|
||||
@Test
|
||||
fun `non-web schemes are refused`() {
|
||||
assertFalse(StreamDisclosure.acceptable("file:///etc/passwd"))
|
||||
assertFalse(StreamDisclosure.acceptable("javascript:alert(1)"))
|
||||
assertFalse(StreamDisclosure.acceptable("rtsp://example.com/s"))
|
||||
}
|
||||
|
||||
/** ⚠️ A destination nobody can name is one nobody can consent to. */
|
||||
@Test
|
||||
fun `a url with no host is refused`() {
|
||||
assertFalse(StreamDisclosure.acceptable("https://"))
|
||||
assertFalse(StreamDisclosure.acceptable("not a url"))
|
||||
assertFalse(StreamDisclosure.acceptable(""))
|
||||
}
|
||||
|
||||
// -- The wire ------------------------------------------------------------
|
||||
|
||||
private fun ref() = Attachment.Ref(
|
||||
kind = Attachment.STORY, id = "abc", key = ByteArray(32) { 1 },
|
||||
digest = ByteArray(32) { 2 }, bytes = 10, mime = "image/jpeg",
|
||||
width = 1, height = 1, duration = 0.0, expires = 0.0)
|
||||
|
||||
@Test
|
||||
fun `a stream url survives the wire`() {
|
||||
val wire = Content.attachment(
|
||||
ref(), caption = "live now", streamUrl = "https://live.example.com/s.m3u8")
|
||||
val parsed = Content.parse(wire)
|
||||
assertEquals("https://live.example.com/s.m3u8", parsed.streamUrl)
|
||||
assertEquals("live now", parsed.text)
|
||||
}
|
||||
|
||||
/** An ordinary story must be unchanged on the wire. */
|
||||
@Test
|
||||
fun `an ordinary story carries no stream`() {
|
||||
val wire = Content.attachment(ref(), caption = "just a photo")
|
||||
assertNull(Content.parse(wire).streamUrl)
|
||||
assertFalse("stream" in wire)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The host comes from the URL, never the caption. A story captioned
|
||||
* "youtube.com" pointing elsewhere is the phishing shape §6.12 guards
|
||||
* against.
|
||||
*/
|
||||
@Test
|
||||
fun `the host comes from the url not the caption`() {
|
||||
val story = Vault.Story(
|
||||
id = "s1", authorId = 2L, mine = false, ref = ref(),
|
||||
caption = "totally youtube.com", postedAt = 0L,
|
||||
streamUrl = "https://evil.example/s.m3u8")
|
||||
assertEquals("evil.example", story.streamHost)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an ordinary story has no stream host`() {
|
||||
val story = Vault.Story("s1", 2L, false, ref(), "photo", 0L)
|
||||
assertNull(story.streamHost)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user