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:
co-authored by
Claude Opus 4.8
parent
58fb20246e
commit
d56ae82c59
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user