Sealed groups on Android: create, fetch, invitations

Mirrors the iOS side. Create mints a gid, group key, owner signing key and
one random tag per member, seals the roster and hands the relay a blob —
no member id leaves the device. Fetch asks by TAG, never "what groups am I
in", because a sealed relay cannot answer that.

Reachability moved here from the relay, which needed to know who the
members were in order to check it.

⚠️ An invitation is NOT subject to the roster check the group name goes
through, and cannot be: that check asks the relay who is in the group, and
for a group we have not joined yet we cannot fetch it to ask. What guards
it instead is that an invitation is accepted only for a group we hold no
secrets for — a stranger cannot overwrite a group we are really in, which
would make every later roster fail to open and silently empty the
conversation.

The base64 stays inside the engine: `Bytes` is internal there on purpose,
so `SealedRoster` grew `newGroupId`, `newOwnerKey`, `ownerPublic` and
`signRoster` rather than the app reaching past that boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-31 08:56:02 -04:00
co-authored by Claude Opus 4.8
parent 58fb20246e
commit d56ae82c59
7 changed files with 382 additions and 0 deletions
@@ -86,6 +86,24 @@ class Conversations(
fun consumeControlForTesting(fromUser: Long, content: Content) =
consumeControl(fromUser, content)
/** A sealed-group invitation: the tag and key that open one group. */
data class GroupInvitation(val tag: String, val key: ByteArray, val name: String) {
override fun equals(other: Any?): Boolean =
other is GroupInvitation && tag == other.tag &&
key.contentEquals(other.key) && name == other.name
override fun hashCode(): Int = tag.hashCode() * 31 + key.contentHashCode()
}
private val groupInvitations = mutableListOf<Triple<Long, String, GroupInvitation>>()
/** Take the pending invitations, leaving none behind. */
fun takeGroupInvitations(): List<Triple<Long, String, GroupInvitation>> =
synchronized(groupInvitations) {
val out = groupInvitations.toList()
groupInvitations.clear()
out
}
/** Take the pending claims, leaving none behind. */
fun takeClaimedGroupNames(): List<Triple<Long, String, String>> =
synchronized(claimedGroupNames) {
@@ -498,6 +516,14 @@ class Conversations(
theirs.forEach { trust.enforceKeyLogIfServed(it, toUserId) }
}
/**
* ⚠️ Public only so `Session` can send a sealed-group invitation, which is
* an ordinary ratchet plaintext this layer has nothing to say about — it
* carries a tag and a group key that only the app layer knows how to mint.
*/
fun sendPlaintextForGroupInvite(toUserId: Long, text: String): Sent =
sendPlaintext(toUserId, text)
private fun sendPlaintext(toUserId: Long, text: String): Sent {
// 🔴 **The DIRECTORY, not the key route.** `/e2e/keys` pops a one-time
// prekey per device and is metered per contact pair — calling it on
@@ -1381,6 +1407,20 @@ class Conversations(
// actually in the group. `Session` drains this and checks.
val gid = content.raw?.optString("gid")?.takeIf { it.isNotEmpty() }
val gname = content.raw?.optString("gname")?.takeIf { it.isNotEmpty() }
// §8.9b — an invitation carries the tag and key as well.
val gtag = content.raw?.optString("gtag")?.takeIf { it.isNotEmpty() }
val gkey = content.raw?.optString("gkey")?.takeIf { it.isNotEmpty() }
?.let { runCatching { Wire.unb64(it) }.getOrNull() }
?.takeIf { it.size == 32 }
if (gid != null && gtag != null && gkey != null) {
synchronized(groupInvitations) {
groupInvitations += Triple(
fromUser, gid, GroupInvitation(gtag, gkey, gname ?: ""))
while (groupInvitations.size > maxClaimedGroupNames) {
groupInvitations.removeAt(0)
}
}
}
if (gid != null && gname != null) {
synchronized(claimedGroupNames) {
claimedGroupNames += Triple(fromUser, gid, gname)
@@ -396,6 +396,68 @@ class RelayClient(
Group.fromWire(postJson("/e2e/groups", JSONObject()
.put("members", JSONArray(memberIds))))
// -- §8.9b Sealed groups -------------------------------------------------
/** A sealed group as the relay can describe it: a blob and a number. */
data class SealedGroup(
val id: String,
val ownerKey: String,
val rosterBlob: String,
val rosterVersion: Int,
)
/**
* Create a group whose roster the relay cannot read.
*
* ⚠️ The gid is ours to choose. The owner's signature covers it, and a
* client cannot sign an id the relay has not minted yet.
*/
fun createSealedGroup(
gid: String, ownerKey: String, rosterBlob: String, tags: List<String>, sig: String
): SealedGroup {
val o = postJson("/e2e/groups/sealed", JSONObject()
.put("gid", gid)
.put("owner_key", ownerKey)
.put("roster_blob", rosterBlob)
.put("tags", JSONArray(tags))
.put("sig", sig))
return SealedGroup(o.getString("gid"), o.getString("owner_key"),
o.getString("roster_blob"), o.optInt("roster_version", 1))
}
/**
* 🔴 **"Give me the groups these tags open", never "what groups am I in".**
* The relay cannot answer the second question about a sealed group, and a
* client that asked it would be asking for a list only a relay holding the
* social graph could produce.
*/
fun fetchSealedGroups(tags: List<String>): List<SealedGroup> {
val list = postJson("/e2e/groups/fetch", JSONObject().put("tags", JSONArray(tags)))
.optJSONArray("groups") ?: JSONArray()
return (0 until list.length()).map {
val o = list.getJSONObject(it)
SealedGroup(o.getString("gid"), o.getString("owner_key"),
o.getString("roster_blob"), o.optInt("roster_version", 1))
}
}
fun replaceRoster(
groupId: String, blob: String, version: Int, sig: String,
tags: List<String>, newOwnerKey: String? = null,
) {
postJson("/e2e/groups/$groupId/roster", JSONObject()
.put("roster_blob", blob)
.put("roster_version", version)
.put("sig", sig)
.put("tags", JSONArray(tags))
.also { o -> newOwnerKey?.let { o.put("new_owner_key", it) } })
}
/** Leaving is surrendering the tag that let you in. */
fun leaveSealedGroup(groupId: String, tag: String) {
postJson("/e2e/groups/$groupId/leave-sealed", JSONObject().put("tag", tag))
}
fun listGroups(): List<Group> {
val list = getJson("/e2e/groups").optJSONArray("groups") ?: JSONArray()
return (0 until list.length()).map { Group.fromWire(list.getJSONObject(it)) }
@@ -2,6 +2,7 @@ package io.flamenet.client
import io.flamenet.e2e.Content
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.SealedRoster
import io.flamenet.e2e.SealedSender
import io.flamenet.e2e.Vault
import okhttp3.mockwebserver.MockResponse
@@ -353,6 +354,64 @@ class GroupNameTest {
assertTrue(2L in asked && 3L in asked, "a member was missed: $asked")
}
// -- Sealed group invitations (§8.9b) --------------------------------------
/**
* 🔴 **The invitation is the only way a member ever gets in.** The relay
* holds neither the tag nor the group key, by construction, so somebody who
* is not told cannot fetch the group at all — there is nobody left to ask.
*/
@Test
fun `an invitation carries the tag and the group key`() {
val conversations = conversations(vault())
val key = SealedRoster.newGroupKey()
val tag = SealedRoster.newTag()
conversations.consumeControlForTesting(7L, Content.parse(
SealedSender.sealedGroupEnvelope(ByteArray(32), "g1", tag, key, "Book club")))
val invites = conversations.takeGroupInvitations()
assertEquals(1, invites.size)
assertEquals("g1", invites[0].second)
assertEquals(tag, invites[0].third.tag)
assertTrue(key.contentEquals(invites[0].third.key))
assertEquals("Book club", invites[0].third.name)
}
/**
* ⚠️ A group key that is not 32 bytes is not a group key. Storing one would
* make every roster for that group fail to open, which reads as a group
* with no members rather than as a bad invitation.
*/
@Test
fun `an invitation with a malformed key is ignored`() {
val conversations = conversations(vault())
// ⚠️ Built by hand rather than through `sealedGroupEnvelope`, which
// only ever emits a well-formed key — the shape under test is one a
// hostile or broken sender produces, not one this engine can make.
fun b64(b: ByteArray) = java.util.Base64.getEncoder().encodeToString(b)
val wire = """{"fnc":1,"kind":"delivery_key","key":"${b64(ByteArray(32))}",""" +
""""gid":"g1","gname":"G","gtag":"${SealedRoster.newTag()}",""" +
""""gkey":"${b64(ByteArray(8))}"}"""
conversations.consumeControlForTesting(7L, Content.parse(wire))
assertTrue(conversations.takeGroupInvitations().isEmpty())
}
/** ⚠️ Drained once, so a poll does not re-apply the same invitation. */
@Test
fun `invitations are taken once`() {
val conversations = conversations(vault())
conversations.consumeControlForTesting(7L, Content.parse(
SealedSender.sealedGroupEnvelope(
ByteArray(32), "g1", SealedRoster.newTag(),
SealedRoster.newGroupKey(), "G")))
assertEquals(1, conversations.takeGroupInvitations().size)
assertTrue(conversations.takeGroupInvitations().isEmpty())
}
// -- Where it is kept ------------------------------------------------------
@Test