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:
Paul Hitt
2026-08-29 13:14:08 -04:00
co-authored by Claude Opus 4.8
parent b8ffe2ea71
commit 1bbf8b460b
6 changed files with 244 additions and 2 deletions
@@ -59,6 +59,8 @@ data class Content(
* member on a group message.
*/
val link: LinkPreview? = null,
/** A live stream this story embeds (§6.13). */
val streamUrl: String? = null,
) {
/**
@@ -297,6 +299,8 @@ data class Content(
groupId: String? = null,
messageId: String? = null,
about: Reference? = null,
/** A live stream this story embeds (§6.13); [ref] is then a poster. */
streamUrl: String? = null,
): String = assemble(
kind = ref.kind,
caption = caption?.takeIf { it.isNotEmpty() },
@@ -305,6 +309,7 @@ data class Content(
attachment = ref,
messageId = messageId,
about = about,
streamUrl = streamUrl,
)
/**
@@ -346,6 +351,7 @@ data class Content(
messageId: String? = null,
about: Reference? = null,
link: LinkPreview? = null,
streamUrl: String? = null,
): String {
val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}")
messageId?.let { parts += "\"mid\":${quote(it)}" }
@@ -355,6 +361,7 @@ data class Content(
groupId?.let { parts += "\"gid\":${quote(it)}" }
attachment?.let { parts += "\"att\":${it.toJson()}" }
link?.let { parts += "\"lnk\":${it.toJson()}" }
streamUrl?.let { parts += "\"stream\":${quote(it)}" }
return parts.joinToString(",", prefix = "{", postfix = "}")
}
@@ -461,7 +468,8 @@ data class Content(
groupId = groupId, messageId = messageId, about = about,
)
return Content(caption, kind = kind, raw = json, attachment = ref,
groupId = groupId, messageId = messageId, about = about)
groupId = groupId, messageId = messageId, about = about,
streamUrl = json.optString("stream").takeIf { it.isNotEmpty() })
}
}
}
@@ -0,0 +1,40 @@
package io.flamenet.e2e
/**
* What a viewer is told before an embedded live stream connects (spec §6.13).
*
* 🔴 **This is the one media path where the viewer must contact a third party,
* and it cannot be designed away.** A photo or video story is a sealed blob:
* the relay stores ciphertext and nobody else is involved. A live stream is
* continuous and hosted elsewhere, so there is nothing to seal — watching it
* means the viewer's device connects to that host, which learns their IP
* address and that they watched.
*
* §6.12's answer (have the sender fetch it once) does not transfer: a stream is
* not a thing that can be fetched once. So the rule is disclosure rather than
* prevention, and this is where the wording lives so it can be asserted.
*/
object StreamDisclosure {
/**
* ⚠️ Names the host and says what it learns. "This will play a video" is a
* button; a disclosure names the destination, because nobody can make a
* decision about "somewhere on the internet".
*/
fun text(host: String): String =
"Playing connects you to $host, which will see your IP address and " +
"that you watched. Nothing else in this story leaves your device."
/**
* Whether a stream URL is one worth offering at all.
*
* ⚠️ Refuses anything that is not `http(s)` with a host. A `file:` URL
* arriving from another person is not something to hand a media player, and
* a destination that cannot be named cannot be consented to.
*/
fun acceptable(raw: String): Boolean = runCatching {
val uri = java.net.URI(raw)
val scheme = uri.scheme?.lowercase()
(scheme == "http" || scheme == "https") && !uri.host.isNullOrEmpty()
}.getOrDefault(false)
}
@@ -362,8 +362,23 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
val caption: String,
val postedAt: Long,
val seen: Boolean = false,
/**
* A live stream this story embeds, if it is one (§6.13).
*
* 🔴 **Playing it connects the VIEWER to the stream host**, revealing
* their address and that they watched. A live stream is continuous and
* hosted elsewhere, so unlike a link preview there is nothing to seal
* and no sender-side trick that avoids it. The URL is hidden from the
* relay; the connection is not hidden from the host — so [ref] is a
* POSTER, and playback is an explicit, host-named tap.
*/
val streamUrl: String? = null,
) {
fun isActive(nowSeconds: Double): Boolean = ref.expires <= 0 || ref.expires > nowSeconds
/** The host a viewer would connect to, for the disclosure. */
val streamHost: String?
get() = streamUrl?.let { runCatching { java.net.URI(it).host }.getOrNull() }
}
// -- Avatars -------------------------------------------------------------
@@ -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)
}
}