Understand a file somebody sent #62

Merged
paulhitt merged 27 commits from feat/files-and-wallet-parity into main 2026-09-04 20:49:06 +00:00
4 changed files with 220 additions and 1 deletions
Showing only changes of commit 32e2f6b5df - Show all commits
@@ -954,6 +954,16 @@ class Session(
}
val ref = message.content.attachment
if (ref != null && ref.kind == Attachment.STORY && message.fromAddress == null) {
// 🔴 A story is caught before the group-message branch, so
// without this check a sender could attach the id of a group
// they are not in and have their story shown as having come
// from it. Dropped to null rather than refused: the story
// itself is legitimately theirs, and only the claim about
// where it was posted is unverified.
val claimed = message.content.groupId
val storyGid = claimed?.takeIf { g ->
groupRoster(g)?.contains(message.fromUser) == true
}
val story = Vault.Story(
id = ref.id,
authorId = message.fromUser,
@@ -961,6 +971,8 @@ class Session(
ref = ref,
caption = message.content.text,
postedAt = System.currentTimeMillis(),
set = message.content.storySet,
gid = storyGid,
)
if (story.isActive(now)) incomingStories += story
continue
@@ -1079,6 +1091,54 @@ class Session(
/** Empty the archive (ios#54). */
fun clearStoryArchive() = vault.saveStoryArchive(emptyList())
/**
* Post a story to a group (§6.24).
*
* 🔴 **The group's roster is the audience, and `storyAudience` does NOT
* apply.** Two different lists making two different statements — who may
* see this account's stories, and who is in a conversation — and they will
* disagree. The roster wins: a story addressed to a group that silently
* dropped members would be a group message part of the group cannot see.
* The caller is responsible for having disclosed that before the first one.
*
* ⚠️ Recorded in the STORY set, never the group thread. `gid` names the
* audience, not a destination — a story rendered into the conversation
* would be a story that is a message, and one that never expires out of it.
*/
fun postGroupStory(
bytes: ByteArray,
mime: String,
width: Int,
height: Int,
caption: String?,
gid: String,
memberIds: List<Long>,
lifetimeSeconds: Double,
): Int {
val recipients = memberIds.filter { it != userId }
if (recipients.isEmpty()) return 0
val posted = conversations.postStory(
bytes, mime, width, height, caption, recipients,
lifetimeSeconds = lifetimeSeconds, groupId = gid)
if (posted.reached > 0) {
val now = System.currentTimeMillis() / 1000.0
vault.saveStories(
vault.loadStories() + Vault.Story(
id = posted.ref.id,
authorId = userId,
mine = true,
ref = posted.ref,
caption = caption.orEmpty(),
postedAt = System.currentTimeMillis(),
seen = true,
gid = gid,
),
now,
)
}
return posted.reached
}
fun reactionsFor(storyKey: String): Map<Long, String> = storyReactions[storyKey].orEmpty()
fun myReactionTo(storyKey: String): String? = storyReactions[storyKey]?.get(userId)
@@ -345,6 +345,14 @@ class Conversations(
* the viewer's device contacts that host directly.
*/
streamUrl: String? = null,
/**
* The group this story is addressed to (§6.24).
*
* ⚠️ Names the AUDIENCE, not a destination thread. It rides on the
* ordinary story envelope; the recipient decides whether to believe
* the claim by checking its own roster.
*/
groupId: String? = null,
): StoryPosted {
val sealed = Attachment.seal(data)
val id = relay.uploadAttachment(sealed.blob)
@@ -354,7 +362,7 @@ class Conversations(
duration = duration,
expires = System.currentTimeMillis() / 1000.0 + lifetimeSeconds,
)
val body = Content.attachment(ref, caption, null, streamUrl = streamUrl)
val body = Content.attachment(ref, caption, groupId, streamUrl = streamUrl)
var reached = 0
var missed = 0
@@ -103,6 +103,42 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
private const val MAX_GROUP_NAME = 80
/** The two kinds of thread, named once so they cannot drift apart. */
/**
* Who a group story will reach, and who among them this account has
* excluded from its stories (§6.24).
*
* 🔴 **The roster decides; the story audience does not apply.** Those
* are two different lists making two different statements, and they
* will disagree. This returns the overlap rather than a warning,
* because a sender who believes their story audience still applies is
* being misled by the interface, and a disclosure that cannot name the
* consequence is not one.
*
* ⚠️ The two categories do not double-count. Somebody who is not a
* contact is not "excluded from your stories" — they were never on
* that list at all.
*/
fun groupStoryReach(
members: List<Long>,
me: Long,
/**
* Who this account's story audience would have allowed, already
* resolved. Passed in rather than an audience type: the audience
* lives in the app layer and this module has no dependency on it.
*/
allowedByAudience: Set<Long>,
contacts: List<Long>,
): GroupStoryReach {
val recipients = members.filter { it != me }
val contactSet = contacts.toSet()
return GroupStoryReach(
recipients = recipients,
excludedByAudience =
recipients.filter { it in contactSet && it !in allowedByAudience },
notContacts = recipients.filter { it !in contactSet },
)
}
/**
* Collapse a set to the expiry every item shares (§6.23).
*
@@ -503,6 +539,14 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
* this one deletes them because it chooses to. A story is not a secret you
* can take back, and the UI should not suggest otherwise.
*/
data class GroupStoryReach(
val recipients: List<Long>,
/** Members this account excluded from its stories, who get it anyway. */
val excludedByAudience: List<Long>,
/** Members who are not contacts at all. */
val notContacts: List<Long>,
)
data class Story(
val id: String,
val authorId: Long,
@@ -522,6 +566,16 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
val expiresAt: Double? = null,
/** Which sequence this story belongs to (§6.23), if any. */
val set: Content.StorySet? = null,
/**
* The group this story was posted to (§6.24), if any.
*
* ⚠️ Names the AUDIENCE, not a destination thread. A group story
* belongs in the story surface; rendering it into the group's messages
* would make a story into a message, which it is not.
*
* 🔴 Persisted. Without it a reload forgets which story went where.
*/
val gid: String? = null,
/**
* A live stream this story embeds, if it is one (§6.13).
*
@@ -769,6 +823,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
seen = o.optBoolean("seen", false),
expiresAt = if (o.has("expires")) o.optDouble("expires") else null,
set = o.optJSONObject("set")?.let { Content.StorySet.fromJson(it) },
gid = o.optString("gid").takeIf { it.isNotEmpty() },
)
}
}
@@ -803,6 +858,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
// relying on it.
story.ref?.let { row.put("ref", it.toJson()) }
story.set?.let { row.put("set", it.toJson()) }
story.gid?.let { row.put("gid", it) }
array.put(row)
}
return array
@@ -0,0 +1,95 @@
package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Stories posted to a group (spec §6.24, ios#55).
*
* 🔴 **The group's roster is the audience, and the sender's story audience does
* not apply.** Two different lists making two different statements — who may
* see this account's stories, and who is in a conversation. They will disagree,
* and the roster wins.
*/
class GroupStoryTest {
@Test
fun `every member but yourself is a recipient`() {
val reach = Vault.groupStoryReach(
members = listOf(1, 2, 3), me = 1,
allowedByAudience = setOf(2, 3), contacts = listOf(2, 3))
assertEquals(listOf(2L, 3L), reach.recipients)
}
/**
* 🔴 The hazard the disclosure exists for: somebody excluded from this
* account's stories still receives a story posted to a group they are in.
*/
@Test
fun `a member excluded from your stories still receives a group story`() {
val reach = Vault.groupStoryReach(
members = listOf(1, 2, 3), me = 1,
allowedByAudience = setOf(2), contacts = listOf(2, 3))
assertEquals(listOf(2L, 3L), reach.recipients)
assertEquals(listOf(3L), reach.excludedByAudience)
}
/** The other half: a member who is not a contact at all. */
@Test
fun `a member who is not a contact is named separately`() {
val reach = Vault.groupStoryReach(
members = listOf(1, 2, 9), me = 1,
allowedByAudience = setOf(2), contacts = listOf(2))
assertEquals(listOf(9L), reach.notContacts)
assertTrue(reach.excludedByAudience.isEmpty())
}
/**
* ⚠️ The categories must not double-count. Somebody who is not a contact
* is not "excluded from your stories" — they were never on that list.
*/
@Test
fun `a non-contact is not also counted as excluded`() {
val reach = Vault.groupStoryReach(
members = listOf(1, 9), me = 1,
allowedByAudience = emptySet(), contacts = emptyList())
assertEquals(listOf(9L), reach.notContacts)
assertTrue(reach.excludedByAudience.isEmpty())
}
@Test
fun `nothing is disclosed when the lists agree`() {
val reach = Vault.groupStoryReach(
members = listOf(1, 2), me = 1,
allowedByAudience = setOf(2), contacts = listOf(2))
assertTrue(reach.excludedByAudience.isEmpty())
assertTrue(reach.notContacts.isEmpty())
}
// -- Storage --------------------------------------------------------------
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()
}
/** ⚠️ Without persisting `gid`, a reload forgets which story went where. */
@Test
fun `the group survives the vault round trip`() {
val vault = Vault(MapStorage(),
javax.crypto.KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
val now = System.currentTimeMillis() / 1000.0
vault.saveStories(listOf(Vault.Story(
id = "s1", authorId = 7, mine = false,
ref = Attachment.Ref(
kind = Attachment.STORY, id = "b1", key = ByteArray(32),
digest = ByteArray(32), bytes = 1, mime = "image/jpeg",
width = 1, height = 1, duration = 0.0, expires = now + 600),
caption = "", postedAt = 1, gid = "g1")), now)
assertEquals("g1", vault.loadStories().single().gid)
}
}