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
@@ -5,6 +5,7 @@ import io.flamenet.client.MessengerApi
import io.flamenet.client.RelayClient
import io.flamenet.e2e.Attachment
import io.flamenet.e2e.ContactCard
import io.flamenet.e2e.SealedRoster
import io.flamenet.e2e.Content
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.Vault
@@ -693,6 +694,7 @@ class Session(
// Control traffic consumed during the poll may have carried group-name
// claims. Checked against the roster here, where the roster is known.
drainGroupNames()
drainGroupInvitations()
everything.forEach { message ->
// ⚠️ Local calls only. Signalling is addressed by user id the
// whole way down — the answer, the ICE candidates, the hang-up —
@@ -915,6 +917,94 @@ class Session(
*/
private var groupRosterCache: Pair<Long, Map<String, Set<Long>>>? = null
// -- Sealed groups (§8.9b) ------------------------------------------------
/**
* Create a group whose roster the relay cannot read.
*
* 🔴 **No member id leaves this device.** The relay is handed a random gid,
* an owner public key, one opaque tag per member, and a blob. What it stored
* before was `gid -> user_id`, one readable row per membership.
*
* ⚠️ Reachability is checked HERE now, not by the relay — it used to ask
* "does this account have a device", which required knowing who the members
* were. The client suffers an unreachable member anyway, since fan-out is
* per device.
*/
fun createSealedGroup(name: String, memberIds: List<Long>): RelayClient.Group {
val members = (memberIds + userId).distinct().sorted()
for (member in members) {
if (member == userId) continue
require(relay.listDevices(member).length() > 0) {
"that member has no device to receive on"
}
}
val gid = SealedRoster.newGroupId()
val groupKey = SealedRoster.newGroupKey()
val owner = SealedRoster.newOwnerKey()
val tags = members.associate { it.toString() to SealedRoster.newTag() }
val blob = SealedRoster.seal(
SealedRoster.Contents(members, tags, name), groupKey)
val sig = SealedRoster.signRoster(owner.seed, gid, 1, blob)
relay.createSealedGroup(gid, SealedRoster.ownerPublic(owner), blob,
tags.values.sorted(), sig)
vault.saveGroupSecrets(gid, Vault.GroupSecrets(
tag = tags[userId.toString()] ?: "", key = groupKey,
ownerPrivate = owner.seed, version = 1))
vault.saveGroupName(gid, name)
announceSealedGroup(gid, tags, groupKey, name, members)
return RelayClient.Group(gid, name, userId, members)
}
/**
* The groups this device can open, which is the only such list anywhere.
*
* 🔴 The relay is asked by TAG. It cannot be asked "what groups am I in",
* because after sealing it does not know.
*/
fun sealedGroups(): List<RelayClient.Group> {
val secrets = vault.allGroupSecrets()
if (secrets.isEmpty()) return emptyList()
val byTag = secrets.entries.associate { it.value.tag to it.key }
val fetched = runCatching { relay.fetchSealedGroups(byTag.keys.toList()) }
.getOrElse { return emptyList() }
val out = ArrayList<RelayClient.Group>()
for (row in fetched) {
val secret = secrets[row.id] ?: continue
// ⚠️ A blob that will not open is skipped, never treated as an empty
// group: dropping every member from a conversation that still exists
// is worse than showing it stale.
val contents = runCatching { SealedRoster.open(row.rosterBlob, secret.key) }
.getOrNull() ?: continue
vault.saveGroupSecrets(row.id, secret.copy(version = row.rosterVersion))
if (contents.name.isNotEmpty()) vault.saveGroupName(row.id, contents.name)
out += RelayClient.Group(row.id, contents.name, userId, contents.members)
}
return out
}
/** Hand each member their tag and the group key, over the ratchet. */
private fun announceSealedGroup(
gid: String, tags: Map<String, String>, key: ByteArray,
name: String, members: List<Long>,
) {
val mine = vault.loadDelivery()?.key ?: return
for (member in members) {
val tag = tags[member.toString()] ?: continue
val payload = io.flamenet.e2e.SealedSender.sealedGroupEnvelope(
mine, gid, tag, key, name)
// Our own other devices included, for the same reason names are:
// fan-out excludes us, so a second phone we own is told by nobody.
runCatching { conversations.sendPlaintextForGroupInvite(member, payload) }
}
}
/**
* The groups this account is in, each carrying the name we hold locally.
*
@@ -978,6 +1068,29 @@ class Session(
}
}
/**
* §8.9b — invitations: a tag and the group key, arriving over the ratchet.
*
* 🔴 **The only way these ever reach us.** The relay holds neither, by
* construction, so a member who is not told cannot fetch the group at all.
*
* ⚠️ **Not subject to the roster check the 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 this 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.
*/
private fun drainGroupInvitations() {
for ((_, gid, invite) in conversations.takeGroupInvitations()) {
if (vault.groupSecrets(gid) != null) continue
vault.saveGroupSecrets(gid, Vault.GroupSecrets(
tag = invite.tag, key = invite.key, ownerPrivate = null, version = 1))
if (invite.name.isNotEmpty()) vault.saveGroupName(gid, invite.name)
groupNamesUpdated = true
}
}
/**
* Set when a group name changed, so the UI knows to re-read the list.
* Control traffic touches no thread, so without a flag a name that arrived