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:
Paul Hitt
2026-09-15 16:33:08 -04:00
co-authored by Claude Opus 5
parent 18d62551d8
commit 7ebd519942
7 changed files with 295 additions and 5 deletions
@@ -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.
*/