Interop driver: report the session version a responder settled on

`session_respond` returned no version at all, so the cross-engine test
could not assert the one thing that mattered — that a session this
device ANSWERS agrees with the initiator about which protocol it runs.
`session_initiate` and `session_encrypt` report it too, so a version
that changes across a store-and-reopen is visible rather than inferred.

Sealed groups: file the message instead of dropping it in silence.
`groupRoster` asked the relay for the membership of a group the relay
cannot know — §8.9b exists so it holds opaque tags and no member list —
so the sender was never "in the roster" and EVERY message in EVERY
sealed group was discarded on arrival by the check meant to protect it.
The sealed roster is the authoritative list and the only one that
exists. A refusal now says which group and which sender.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-01 09:21:19 -04:00
co-authored by Claude Opus 4.8
parent ee17d773b7
commit 2402f7a665
2 changed files with 30 additions and 3 deletions
@@ -762,6 +762,13 @@ class Session(
if (gid != null) { if (gid != null) {
val known = groupRoster(gid) val known = groupRoster(gid)
if (known == null || message.fromUser !in known) { 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 continue
} }
// ⚠️ Every group envelope carries the group's name, and this is // ⚠️ Every group envelope carries the group's name, and this is
@@ -1244,6 +1251,16 @@ class Session(
@Volatile var groupNamesUpdated: Boolean = false @Volatile var groupNamesUpdated: Boolean = false
private fun groupRoster(gid: String): Set<Long>? { private fun groupRoster(gid: String): Set<Long>? {
// 🔴 **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 now = System.currentTimeMillis()
val cached = groupRosterCache val cached = groupRosterCache
if (cached != null && now - cached.first < 30_000) return cached.second[gid] if (cached != null && now - cached.first < 30_000) return cached.second[gid]
@@ -359,6 +359,7 @@ private fun sessionInitiate(req: JSONObject): JSONObject {
// layer where the one bug this protocol has actually shipped lives: a // layer where the one bug this protocol has actually shipped lives: a
// client and a relay disagreeing about a field name, caught by nothing // client and a relay disagreeing about a field name, caught by nothing
// until a live round trip. // until a live round trip.
.put("version", session.version)
.put("envelope", session.encrypt(req.getString("text")).toJson()) .put("envelope", session.encrypt(req.getString("text")).toJson())
} }
@@ -371,6 +372,12 @@ private fun sessionRespond(req: JSONObject): JSONObject {
return JSONObject() return JSONObject()
.put("session", id) .put("session", id)
.put("ad", b64(session.associatedData)) .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)) .put("text", session.decrypt(envelope))
} }
@@ -407,9 +414,12 @@ private fun openSealed(req: JSONObject): JSONObject {
.put("envelope", opened.envelope.toJson()) .put("envelope", opened.envelope.toJson())
} }
private fun sessionEncrypt(req: JSONObject): JSONObject = JSONObject() private fun sessionEncrypt(req: JSONObject): JSONObject =
.put("envelope", liveSessions.getValue(req.getString("session")) liveSessions.getValue(req.getString("session")).let { session ->
.encrypt(req.getString("text")).toJson()) JSONObject()
.put("version", session.version)
.put("envelope", session.encrypt(req.getString("text")).toJson())
}
private fun sessionDecrypt(req: JSONObject): JSONObject = JSONObject() private fun sessionDecrypt(req: JSONObject): JSONObject = JSONObject()
.put("text", liveSessions.getValue(req.getString("session")) .put("text", liveSessions.getValue(req.getString("session"))