diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 27d6965..fc21e32 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -585,7 +585,21 @@ class Session( // ⚠️ A group message is filed under the GROUP, not the sender. // Filing by sender scatters one conversation across as many threads // as it has participants. - val thread = message.content.groupId + // + // 🔴 **Both memberships are checked first (audit H1).** Filing on + // the sender's word alone let a contact conjure a group thread we + // are not in, and let anyone we share a session with — including a + // member we REMOVED, whose 1:1 session outlives their membership — + // post into a group they do not belong to. The roster is the + // relay's; a group it does not list for us is not ours. + val gid = message.content.groupId + if (gid != null) { + val known = groupRoster(gid) + if (known == null || message.fromUser !in known) { + continue + } + } + val thread = gid ?.let(Vault::threadForGroup) ?: Vault.threadForUser(message.fromUser) record(thread, message.content.text, mine = false, @@ -716,6 +730,24 @@ class Session( return posted } + /** + * Members of a group we are in, or null if the relay does not list it for + * us (audit H1). Cached for a short window: a poll pass can carry many + * group messages and each must not cost a round trip. + */ + private var groupRosterCache: Pair>>? = null + + private fun groupRoster(gid: String): Set? { + val now = System.currentTimeMillis() + val cached = groupRosterCache + if (cached != null && now - cached.first < 30_000) return cached.second[gid] + val fresh = runCatching { + relay.listGroups().associate { it.id to it.memberIds.toSet() } + }.getOrNull() ?: return cached?.second?.get(gid) + groupRosterCache = now to fresh + return fresh[gid] + } + // -- Contact list (P1) --------------------------------------------------- /**