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
@@ -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 ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user