From d1b0acdda67da9cc5a8777e2722ed935fd46f6c7 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sat, 29 Aug 2026 08:35:32 -0400 Subject: [PATCH] Audit H1: a group message is filed only if BOTH ends are members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filing on the sender's word let a contact conjure a group thread we are not in, and let anyone sharing a session with us — including a member we removed — post into a group they had left. The relay's roster decides, cached briefly so a poll pass carrying many group messages costs one round trip rather than one each. Co-Authored-By: Claude Opus 4.8 --- .../io/flamenet/messenger/app/Session.kt | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) 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) --------------------------------------------------- /**