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