Presence without handing the relay a contact graph
Spec 8.9c, the Android half, matching iOS. Each contact gets a random 32-byte token, sent over the encrypted channel on the delivery-key envelope, which is already the per-contact one. The relay is given only SHA-256 of it, and presenting the token is what reads a presence. Per pair rather than one shared secret, so taking one contact off the list costs the others nothing. That is the difference from the sealed-sender delivery key, where cutting somebody off replaces a key everybody holds. Where a token is held its answer wins; a contact with no token yet keeps whatever the roster said, so a half-migrated pair does not read as offline while both ends catch up. The HTTP stays out of the engine. Conversations has the vault and the e2e surface but not the mobile one, so it mints the token and raises a flag, the same arrangement avatarsUpdated already uses, and Session publishes. A star inside a block comment opens a nested one in Kotlin, which is how a doc comment naming a route path took out the whole file. The path is described rather than written now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
18d62551d8
commit
7ebd519942
@@ -2116,12 +2116,41 @@ class Session(
|
||||
)
|
||||
}
|
||||
vault.saveRoster(mirror, offServer = false)
|
||||
fetched
|
||||
// §8.9c. Where this account holds a contact's token, the token's
|
||||
// answer wins: it is the one that does not depend on the relay
|
||||
// keeping a copy of who knows whom. A contact with no token yet
|
||||
// keeps whatever the roster said, so a half-migrated pair does not
|
||||
// read as offline while both ends catch up.
|
||||
val byToken = presenceFromTokens()
|
||||
if (byToken.isEmpty()) fetched else fetched.map { b ->
|
||||
byToken[b.userId]?.let { b.copy(presence = it) } ?: b
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (items.length() > 0) stored() else throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* §8.9c. Presence for the contacts this account holds a token for.
|
||||
*
|
||||
* Also the moment the digests are republished, when a token was minted
|
||||
* since the last pass. Both are best effort: a relay that refuses either
|
||||
* leaves presence exactly where the roster put it.
|
||||
*/
|
||||
private fun presenceFromTokens(): Map<Long, String> {
|
||||
val held = conversations.presenceTokensHeld()
|
||||
if (conversations.rosterTagsDirty) {
|
||||
conversations.rosterTagsDirty = false
|
||||
runCatching { api.publishRosterTags(conversations.rosterTagDigests()) }
|
||||
}
|
||||
if (held.isEmpty()) return emptyMap()
|
||||
val answers = runCatching { api.presenceByToken(held.keys.toList()) }
|
||||
.getOrDefault(emptyMap())
|
||||
return answers.mapNotNull { (token, presence) ->
|
||||
held[token]?.let { it to presence }
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
fun rosterOffServer(): Boolean = vault.loadRoster().second
|
||||
|
||||
fun rosterAddLocal(
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import io.flamenet.e2e.SealedSender
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* §8.9c. The relay used to keep a plaintext contact graph, because presence is
|
||||
* shown only to people on your list and it kept the list to enforce that. A
|
||||
* copy of the database was therefore a copy of everybody's contacts.
|
||||
*
|
||||
* Now each contact gets a random token, sent over the encrypted channel, and
|
||||
* the relay holds only SHA-256 of it. Presenting the token is what reads the
|
||||
* presence. Mirrors `PresenceWithoutAContactGraphTests` on iOS.
|
||||
*/
|
||||
class PresenceWithoutAContactGraphTest {
|
||||
private val key = ByteArray(32) { 7 }
|
||||
|
||||
@Test
|
||||
fun `the delivery key envelope carries the presence token`() {
|
||||
val token = java.util.Base64.getEncoder().encodeToString(ByteArray(32) { 9 })
|
||||
val json = JSONObject(SealedSender.deliveryKeyEnvelope(key, presenceToken = token))
|
||||
assertEquals("delivery_key", json.getString("kind"))
|
||||
assertEquals(token, json.getString("ptok"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Absent, not empty. An older build parses this envelope and must not find
|
||||
* a field it would take for a token.
|
||||
*/
|
||||
@Test
|
||||
fun `no token means no field at all`() {
|
||||
val json = SealedSender.deliveryKeyEnvelope(key)
|
||||
assertFalse(json.contains("ptok"), json)
|
||||
assertFalse(JSONObject(json).has("ptok"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `it is still the control envelope it always was`() {
|
||||
val token = java.util.Base64.getEncoder().encodeToString(ByteArray(32) { 9 })
|
||||
val json =
|
||||
JSONObject(
|
||||
SealedSender.deliveryKeyEnvelope(
|
||||
key,
|
||||
tokens = listOf("AAAA"),
|
||||
presenceToken = token,
|
||||
),
|
||||
)
|
||||
assertEquals("delivery_key", json.getString("kind"))
|
||||
assertEquals(1, json.getJSONArray("tokens").length())
|
||||
assertEquals(token, json.getString("ptok"))
|
||||
}
|
||||
|
||||
/**
|
||||
* The property that makes removing one contact cheap. One shared secret
|
||||
* would have to be replaced for everybody, which is the cost the
|
||||
* sealed-sender delivery key does pay.
|
||||
*/
|
||||
@Test
|
||||
fun `a token is per contact`() {
|
||||
val a = SealedSender.freshPresenceToken()
|
||||
val b = SealedSender.freshPresenceToken()
|
||||
assertEquals(32, a.size)
|
||||
assertFalse(a.contentEquals(b), "two contacts must not share a token")
|
||||
}
|
||||
|
||||
/** The relay is given the digest. It cannot work the token back out. */
|
||||
@Test
|
||||
fun `the relay is given the digest, never the token`() {
|
||||
val token = SealedSender.freshPresenceToken()
|
||||
val digest = SealedSender.presenceDigest(token)
|
||||
assertEquals(32, digest.size)
|
||||
assertFalse(digest.contentEquals(token))
|
||||
// Recomputable by anyone holding the token, which is how the relay
|
||||
// checks it.
|
||||
assertTrue(digest.contentEquals(SealedSender.presenceDigest(token)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokens live in the vault's delivery record, so they survive a restart.
|
||||
* A token that did not would silently cost somebody their contacts'
|
||||
* presence and mint fresh ones nobody holds.
|
||||
*/
|
||||
@Test
|
||||
fun `the vault record carries both directions`() {
|
||||
val vault = File("src/main/kotlin/io/flamenet/e2e/Vault.kt")
|
||||
val engine = File("../engine/src/main/kotlin/io/flamenet/e2e/Vault.kt")
|
||||
val text = (if (vault.isFile) vault else engine).readText()
|
||||
assertTrue(text.contains("val presenceIssued: Map<Long, String>"), "not in Delivery")
|
||||
assertTrue(text.contains("val presenceHeld: Map<Long, String>"), "not in Delivery")
|
||||
// Written AND read back, or a restart loses them.
|
||||
assertTrue(text.contains("\"pissued\""), "issued tokens are never persisted")
|
||||
assertTrue(text.contains("\"pheld\""), "held tokens are never persisted")
|
||||
}
|
||||
|
||||
/** The claim is bounded, and the spec says so. */
|
||||
@Test
|
||||
fun `nothing sends the token itself to the relay`() {
|
||||
val conversations =
|
||||
File("../client/src/main/kotlin/io/flamenet/client/Conversations.kt").readText()
|
||||
assertTrue(
|
||||
conversations.contains("Wire.sha256"),
|
||||
"the digests published must be hashed here",
|
||||
)
|
||||
assertFalse(
|
||||
conversations.contains("publishRosterTags(delivery.presenceIssued"),
|
||||
"that would publish the tokens rather than their digests",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1381,7 +1381,11 @@ class Conversations(
|
||||
if (!delivery.published) return null
|
||||
val needsKey = userId !in delivery.toldPeers
|
||||
val needsTokens = needsDeliveryTokens(userId)
|
||||
if (!needsKey && !needsTokens) return null
|
||||
// §8.9c rides the same envelope and the same first-contact moment: a
|
||||
// contact never handed our delivery key was never handed a presence
|
||||
// token either.
|
||||
val needsPresence = userId !in delivery.presenceIssued
|
||||
if (!needsKey && !needsTokens && !needsPresence) return null
|
||||
|
||||
// §21.4, a fresh batch of single-use capabilities rides the same
|
||||
// envelope as the key.
|
||||
@@ -1392,8 +1396,52 @@ class Conversations(
|
||||
emptyList()
|
||||
}
|
||||
|
||||
vault.saveDelivery(delivery.copy(toldPeers = delivery.toldPeers + userId))
|
||||
return SealedSender.deliveryKeyEnvelope(delivery.key, batch)
|
||||
val presence =
|
||||
delivery.presenceIssued[userId] ?: Wire.b64(SealedSender.freshPresenceToken())
|
||||
vault.saveDelivery(
|
||||
delivery.copy(
|
||||
toldPeers = delivery.toldPeers + userId,
|
||||
presenceIssued = delivery.presenceIssued + (userId to presence),
|
||||
),
|
||||
)
|
||||
if (needsPresence) rosterTagsDirty = true
|
||||
return SealedSender.deliveryKeyEnvelope(delivery.key, batch, presenceToken = presence)
|
||||
}
|
||||
|
||||
/**
|
||||
* §8.9c. Set when a presence token was minted, so the layer that holds the
|
||||
* HTTP client knows to republish the set. Same arrangement as
|
||||
* [avatarsUpdated], and for the same reason: this class has the vault and
|
||||
* the relay's e2e surface, not the mobile one that carries this route.
|
||||
* A star inside a block comment opens a nested one in Kotlin, so the path
|
||||
* is described rather than written.
|
||||
*/
|
||||
@Volatile var rosterTagsDirty: Boolean = false
|
||||
|
||||
/**
|
||||
* §8.9c. The digests standing for this account's contacts, for publishing.
|
||||
*
|
||||
* Digests, never the tokens. Sorted so a republish that changed nothing
|
||||
* sends the same bytes as the last one.
|
||||
*/
|
||||
fun rosterTagDigests(): List<String> {
|
||||
val delivery = vault.loadDelivery() ?: return emptyList()
|
||||
return delivery.presenceIssued.values
|
||||
.mapNotNull { token ->
|
||||
runCatching { Wire.b64(Wire.sha256(Wire.unb64(token))) }.getOrNull()
|
||||
}.sorted()
|
||||
}
|
||||
|
||||
/**
|
||||
* §8.9c. The token held for each contact, keyed BY TOKEN, which is the
|
||||
* shape the presence route answers in.
|
||||
*
|
||||
* Empty when none are held, which is how a caller tells "nobody is around"
|
||||
* from "this account has not been through §8.9c yet".
|
||||
*/
|
||||
fun presenceTokensHeld(): Map<String, Long> {
|
||||
val delivery = vault.loadDelivery() ?: return emptyMap()
|
||||
return delivery.presenceHeld.entries.associate { it.value to it.key }
|
||||
}
|
||||
|
||||
// -- §21.4 single-use delivery capabilities
|
||||
@@ -1512,6 +1560,21 @@ class Conversations(
|
||||
}
|
||||
vault.savePeerTokens(fromUser, fresh)
|
||||
}
|
||||
// §8.9c, the token this contact issued for reading their
|
||||
// presence. Read before the early return below, because a
|
||||
// contact can send one without a key.
|
||||
val presence =
|
||||
content.raw
|
||||
?.optString("ptok")
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.takeIf { runCatching { Wire.unb64(it).size }.getOrNull() == 32 }
|
||||
if (presence != null) {
|
||||
vault.loadDelivery()?.let {
|
||||
vault.saveDelivery(
|
||||
it.copy(presenceHeld = it.presenceHeld + (fromUser to presence)),
|
||||
)
|
||||
}
|
||||
}
|
||||
val key = content.raw?.optString("key")?.takeIf { it.isNotEmpty() } ?: return
|
||||
val delivery = vault.loadDelivery() ?: return
|
||||
vault.saveDelivery(delivery.copy(peers = delivery.peers + (fromUser to Wire.unb64(key))))
|
||||
|
||||
@@ -62,6 +62,34 @@ class MessengerApi(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* §8.9c. Publish the digests standing for this account's contacts.
|
||||
*
|
||||
* The whole set every time: a partial update would need the relay to know
|
||||
* which contact a digest was for, which is the thing it must not know.
|
||||
*/
|
||||
fun publishRosterTags(digests: List<String>) {
|
||||
post("roster/tags", JSONObject().put("digests", JSONArray(digests)))
|
||||
}
|
||||
|
||||
/**
|
||||
* §8.9c. The presence of whoever issued these tokens, keyed by the token
|
||||
* that asked. The relay names nobody in the answer.
|
||||
*
|
||||
* A token it does not recognise comes back `offline`, the same as somebody
|
||||
* invisible or stale, so this cannot be used to guess tokens.
|
||||
*/
|
||||
fun presenceByToken(tokens: List<String>): Map<String, String> {
|
||||
if (tokens.isEmpty()) return emptyMap()
|
||||
val rows =
|
||||
post("presence", JSONObject().put("tokens", JSONArray(tokens)))
|
||||
.optJSONArray("presence") ?: JSONArray()
|
||||
return (0 until rows.length()).associate {
|
||||
val row = rows.getJSONObject(it)
|
||||
row.optString("token") to row.optString("presence", "offline")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* By username, not by id.
|
||||
*/
|
||||
|
||||
@@ -44,6 +44,9 @@ internal object Bytes {
|
||||
return diff == 0
|
||||
}
|
||||
|
||||
/** n bytes from the platform's own secure source. */
|
||||
fun random(n: Int): ByteArray = ByteArray(n).also { java.security.SecureRandom().nextBytes(it) }
|
||||
|
||||
fun sha256(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(data)
|
||||
|
||||
fun sha512(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-512").digest(data)
|
||||
|
||||
@@ -135,6 +135,17 @@ object SealedSender {
|
||||
/** What the server stores. Never send the key itself. */
|
||||
fun deliveryKeyDigest(key: ByteArray): ByteArray = Bytes.sha256(key)
|
||||
|
||||
/**
|
||||
* §8.9c. A fresh 32-byte presence token, one per contact.
|
||||
*
|
||||
* Per pair rather than one secret shared with everybody, which is what
|
||||
* makes taking a contact off the list cost the others nothing.
|
||||
*/
|
||||
fun freshPresenceToken(): ByteArray = Bytes.random(32)
|
||||
|
||||
/** What the relay is given: never the token, only this. */
|
||||
fun presenceDigest(token: ByteArray): ByteArray = Bytes.sha256(token)
|
||||
|
||||
/** The control message that hands a contact this key (§6.6, §14.4). */
|
||||
|
||||
/**
|
||||
@@ -163,13 +174,26 @@ object SealedSender {
|
||||
""","gtag":${JSONObject.quote(tag)}""" +
|
||||
""","gkey":${JSONObject.quote(Bytes.b64(groupKey))}}"""
|
||||
|
||||
@JvmOverloads
|
||||
fun deliveryKeyEnvelope(
|
||||
key: ByteArray,
|
||||
tokens: List<String> = emptyList(),
|
||||
groupId: String? = null,
|
||||
groupName: String? = null,
|
||||
/**
|
||||
* §8.9c, the token this contact presents to read our presence. It
|
||||
* rides here because this envelope is already the per-contact one, and
|
||||
* a second envelope would be a second thing to lose.
|
||||
*
|
||||
* Absent rather than empty when there is none: an older build parses
|
||||
* this envelope and must not find a field it would take for a token.
|
||||
*/
|
||||
presenceToken: String? = null,
|
||||
): String {
|
||||
var json = """{"fnc":1,"kind":"${Content.Kind.DELIVERY_KEY}","key":"${Bytes.b64(key)}""""
|
||||
if (presenceToken != null) {
|
||||
json += ""","ptok":${JSONObject.quote(presenceToken)}"""
|
||||
}
|
||||
if (tokens.isNotEmpty()) {
|
||||
json += ""","tokens":[""" + tokens.joinToString(",") { "\"" + it + "\"" } + "]"
|
||||
}
|
||||
|
||||
@@ -388,6 +388,14 @@ class Vault(
|
||||
val peers: Map<Long, ByteArray> = emptyMap(),
|
||||
/** Peers we have already handed OUR key to, so we send it once. */
|
||||
val toldPeers: Set<Long> = emptySet(),
|
||||
/**
|
||||
* §8.9c. The token issued to each contact so they can read this
|
||||
* account's presence, base64 of 32 bytes. Only its SHA-256 ever
|
||||
* reaches the relay.
|
||||
*/
|
||||
val presenceIssued: Map<Long, String> = emptyMap(),
|
||||
/** §8.9c. The token each contact issued to US, for reading theirs. */
|
||||
val presenceHeld: Map<Long, String> = emptyMap(),
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean =
|
||||
other is Delivery && key.contentEquals(other.key) && published == other.published &&
|
||||
@@ -414,9 +422,28 @@ class Vault(
|
||||
.decode(peers.getString(k))
|
||||
},
|
||||
toldPeers = (0 until told.length()).map { told.getLong(it) }.toSet(),
|
||||
presenceIssued = tokenMap(o.optJSONObject("pissued")),
|
||||
presenceHeld = tokenMap(o.optJSONObject("pheld")),
|
||||
)
|
||||
}
|
||||
|
||||
/** A `{ "userId": "token" }` object as a map, or empty when absent. */
|
||||
private fun tokenMap(o: JSONObject?): Map<Long, String> {
|
||||
if (o == null) return emptyMap()
|
||||
return o
|
||||
.keys()
|
||||
.asSequence()
|
||||
.mapNotNull { k -> k.toLongOrNull()?.let { it to o.getString(k) } }
|
||||
.toMap()
|
||||
}
|
||||
|
||||
/** The inverse of [tokenMap]. */
|
||||
private fun tokenJson(tokens: Map<Long, String>): JSONObject {
|
||||
val out = JSONObject()
|
||||
tokens.forEach { (id, token) -> out.put(id.toString(), token) }
|
||||
return out
|
||||
}
|
||||
|
||||
fun saveDelivery(delivery: Delivery) {
|
||||
val peers = JSONObject()
|
||||
delivery.peers.forEach { (id, k) ->
|
||||
@@ -437,7 +464,9 @@ class Vault(
|
||||
.encodeToString(delivery.key),
|
||||
).put("published", delivery.published)
|
||||
.put("peers", peers)
|
||||
.put("told", org.json.JSONArray(delivery.toldPeers.toList())),
|
||||
.put("told", org.json.JSONArray(delivery.toldPeers.toList()))
|
||||
.put("pissued", tokenJson(delivery.presenceIssued))
|
||||
.put("pheld", tokenJson(delivery.presenceHeld)),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user