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 3d510ab..9458092 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -762,6 +762,13 @@ class Session( if (gid != null) { val known = groupRoster(gid) if (known == null || message.fromUser !in known) { + // ⚠️ Refusing is correct — but refusing in silence is what + // made a sealed group look like a group nobody could talk + // in. Say which group and which sender, never the message. + System.err.println( + "fnmsg-recv: NOT FILED into group ${gid.take(8)} — " + + "sender ${message.fromUser} is not in the roster " + + "(roster ${known?.size ?: -1} member(s))") continue } // ⚠️ Every group envelope carries the group's name, and this is @@ -1244,6 +1251,16 @@ class Session( @Volatile var groupNamesUpdated: Boolean = false private fun groupRoster(gid: String): Set? { + // 🔴 **A SEALED group's roster is not the relay's to give.** §8.9b + // exists so the relay holds opaque tags and no membership at all, so + // `listGroups()` returns an EMPTY member list for one — and the caller + // reads that as "the sender is not a member" and drops the message. + // Every message in every sealed group was discarded on arrival, in + // silence, by the check meant to protect it. The sealed roster is the + // authoritative list here and the only one that exists. + if (vault.groupSecrets(gid) != null) { + return sealedGroups().firstOrNull { it.id == gid }?.memberIds?.toSet() + } val now = System.currentTimeMillis() val cached = groupRosterCache if (cached != null && now - cached.first < 30_000) return cached.second[gid] diff --git a/interop/src/main/kotlin/io/flamenet/interop/Main.kt b/interop/src/main/kotlin/io/flamenet/interop/Main.kt index 6a246ff..bcace63 100644 --- a/interop/src/main/kotlin/io/flamenet/interop/Main.kt +++ b/interop/src/main/kotlin/io/flamenet/interop/Main.kt @@ -359,6 +359,7 @@ private fun sessionInitiate(req: JSONObject): JSONObject { // layer where the one bug this protocol has actually shipped lives: a // client and a relay disagreeing about a field name, caught by nothing // until a live round trip. + .put("version", session.version) .put("envelope", session.encrypt(req.getString("text")).toJson()) } @@ -371,6 +372,12 @@ private fun sessionRespond(req: JSONObject): JSONObject { return JSONObject() .put("session", id) .put("ad", b64(session.associatedData)) + // 🔴 **What the session ANSWERED with, not what the harness assumes.** + // Without this the interop test could not see the version a responder + // settled on, and a responder that capped itself below the initiator + // shipped: iOS ran the session as v3, this side as v2, and every + // message sent into it was refused by the peer as a downgrade. + .put("version", session.version) .put("text", session.decrypt(envelope)) } @@ -407,9 +414,12 @@ private fun openSealed(req: JSONObject): JSONObject { .put("envelope", opened.envelope.toJson()) } -private fun sessionEncrypt(req: JSONObject): JSONObject = JSONObject() - .put("envelope", liveSessions.getValue(req.getString("session")) - .encrypt(req.getString("text")).toJson()) +private fun sessionEncrypt(req: JSONObject): JSONObject = + liveSessions.getValue(req.getString("session")).let { session -> + JSONObject() + .put("version", session.version) + .put("envelope", session.encrypt(req.getString("text")).toJson()) + } private fun sessionDecrypt(req: JSONObject): JSONObject = JSONObject() .put("text", liveSessions.getValue(req.getString("session"))