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
@@ -100,6 +100,27 @@ object SealedRoster {
* `Group.rosterCanonical` on the relay and `SealedRoster.canonical` on iOS
* exactly, or every signature this client makes is refused.
*/
/**
* A new group id: 32 random bytes as hex.
*
* ⚠️ Minted here rather than by the app so the app never needs the engine's
* internal encoding helpers — and so the shape stays in one place. It is
* the same shape the relay used to mint, before the owner's signature had
* to cover it.
*/
fun newGroupId(): String =
ByteArray(32).also { random.nextBytes(it) }
.joinToString("") { "%02x".format(it) }
/** An owner keypair for a group we create. */
fun newOwnerKey(): Ed25519.KeyPair = Ed25519.generate()
fun ownerPublic(pair: Ed25519.KeyPair): String = Bytes.b64(pair.publicKey)
/** Sign a roster as its owner. Base64, ready for the wire. */
fun signRoster(ownerSeed: ByteArray, gid: String, version: Int, blob: String): String =
Bytes.b64(Ed25519.sign(ownerSeed, canonical(gid, version, blob)))
fun canonical(gid: String, version: Int, blob: String): ByteArray =
"fngroup-v1|$gid|$version|$blob".toByteArray()
}
@@ -217,6 +217,25 @@ object SealedSender {
* specifies. `delivery_key` is a kind they already consume in silence and
* already ignore unknown fields on.
*/
/**
* §8.9b — hand a member their tag and the group key.
*
* 🔴 **The only way these ever reach anybody.** The relay holds neither, by
* construction, so a member who is not told cannot fetch the group at all —
* there is nobody to ask.
*
* ⚠️ Rides `delivery_key` for the same reason the group name does: a new
* control kind renders as raw JSON on every phone running v5.
*/
fun sealedGroupEnvelope(
key: ByteArray, groupId: String, tag: String, groupKey: ByteArray, name: String
): String =
"""{"fnc":1,"kind":"${Content.Kind.DELIVERY_KEY}","key":"${Bytes.b64(key)}"""" +
""","gid":${JSONObject.quote(groupId)}""" +
""","gname":${JSONObject.quote(name)}""" +
""","gtag":${JSONObject.quote(tag)}""" +
""","gkey":${JSONObject.quote(Bytes.b64(groupKey))}}"""
fun deliveryKeyEnvelope(
key: ByteArray,
tokens: List<String> = emptyList(),
@@ -87,6 +87,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
private const val REPLY_REMINDER = "reply-reminder"
private const val ROSTER = "roster"
private const val GROUP_NAMES = "group-names"
private const val GROUP_SECRETS = "group-secrets"
private const val DEVICE_OWNER_PREFIX = "devowner/"
private const val KT_ISSUER_PIN = "kt-issuer-pin"
/** Kept because the whole log is re-sealed on every write. */
@@ -433,6 +434,73 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
return runCatching { Attachment.Ref.fromJson(Attachment.IMAGE, json, 0.0) }.getOrNull()
}
// -- Sealed group secrets (§8.9b) -----------------------------------------
/**
* What this device holds for one sealed group.
*
* 🔴 **Lose these and the group is gone.** The relay cannot tell you what
* you are a member of — that is the entire point of sealing — so the tag is
* the only way to fetch the group and the key is the only way to read it.
* Both ride the encrypted history export and the group invitation, the same
* paths the group name already uses.
*/
data class GroupSecrets(
/** The opaque token that fetches this group from the relay. */
val tag: String,
/** Opens the roster blob. */
val key: ByteArray,
/** Ed25519 private key iff we own this group — only the owner may
* rewrite a roster, and only with this. */
val ownerPrivate: ByteArray? = null,
/** Last roster version seen, so a rewrite increments rather than
* guesses: the relay refuses a version that does not increase. */
val version: Int = 1,
) {
override fun equals(other: Any?): Boolean =
other is GroupSecrets && tag == other.tag && key.contentEquals(other.key) &&
version == other.version &&
(ownerPrivate?.contentEquals(other.ownerPrivate ?: ByteArray(0))
?: (other.ownerPrivate == null))
override fun hashCode(): Int = tag.hashCode() * 31 + key.contentHashCode()
}
fun groupSecrets(groupId: String): GroupSecrets? =
readJson(GROUP_SECRETS)?.optJSONObject(groupId)?.let { o ->
GroupSecrets(
tag = o.optString("tag"),
key = Bytes.unb64(o.optString("key")),
ownerPrivate = o.optString("owner").takeIf { it.isNotEmpty() }?.let(Bytes::unb64),
version = o.optInt("version", 1),
)
}
fun saveGroupSecrets(groupId: String, secrets: GroupSecrets) {
val json = readJson(GROUP_SECRETS) ?: JSONObject()
json.put(groupId, JSONObject()
.put("tag", secrets.tag)
.put("key", Bytes.b64(secrets.key))
.put("version", secrets.version)
.also { o -> secrets.ownerPrivate?.let { o.put("owner", Bytes.b64(it)) } })
writeJson(GROUP_SECRETS, json)
}
/** Every group this device can open — the only such list that exists. */
fun allGroupSecrets(): Map<String, GroupSecrets> {
val json = readJson(GROUP_SECRETS) ?: return emptyMap()
return json.keys().asSequence().mapNotNull { gid ->
groupSecrets(gid)?.let { gid to it }
}.toMap()
}
/** Forget a group on leaving. The tag is surrendered separately. */
fun clearGroupSecrets(groupId: String) {
val json = readJson(GROUP_SECRETS) ?: return
json.remove(groupId)
writeJson(GROUP_SECRETS, json)
}
// -- Group names ---------------------------------------------------------
/**