Issue, spend and revoke delivery capabilities on Android (§21.4)
The last client. A recipient mints a batch of random tokens per contact, uploads only their hashes, and hands the tokens over inside the ratchet; a sender spends one per message; revoking one contact is dropping one batch, and nobody else notices. The batch rides the existing `delivery_key` envelope rather than a new control kind, because a receiver recognises control traffic by KIND first and falls back to the `ctl` flag only for kinds it has never heard of — and this engine reads that flag with `optBoolean`, which does not coerce the number §6.10 calls for. A `delivery_tokens` kind would have arrived on the phones already out there as a bubble full of raw JSON. Replenishment is counted rather than requested, for the same reason: a "running low" message would have been another new kind. The issuer can count on its own, because every message it receives from a contact spent one of their capabilities, and it mints afresh at half a batch. A refused capability means the batch was revoked, so the rest is discarded and the same envelopes are re-sent on the shared key — they are addressed to the device, not to the authorisation. That is not a way around revocation, and the code says so: revoking moves a sender back onto §14.4's key, it does not silence them. One bug the tests found. `consumeControl` read the peer's delivery key first and bailed with `loadDelivery() ?: return`, so a device that had not published a key of ITS own yet — every device before `ensureDeliveryKey` runs — silently dropped everything in the envelope, capabilities included. Capabilities have their own store and no such dependency, so they are handled before that early return. Verified against a locally-run relay carrying the §21 work as well as by the unit suite: 126 capability hashes on the relay after the live run, so the path is exercised rather than mocked past. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
356c89e1cd
commit
79c2734f86
@@ -507,15 +507,19 @@ class Conversations(
|
||||
return Sent(delivered, failed, sealed = false)
|
||||
}
|
||||
|
||||
// Seal only if we hold their key. A silent fall back to an
|
||||
// unsealed send is the failure §14.5 warns about, so the outcome is
|
||||
// returned rather than assumed — the UI can then say which it was
|
||||
// instead of implying anonymity that was not achieved.
|
||||
// Seal if we hold either authorisation. §21.4's capability is preferred:
|
||||
// it is revocable for this contact alone, and the relay sees a fresh
|
||||
// value per message rather than one constant per recipient. A silent
|
||||
// fall back from sealed to unsealed is the failure §14.5 warns about,
|
||||
// so the outcome is still returned rather than assumed.
|
||||
val peerKey = deliveryKeyFor(toUserId)
|
||||
val peerToken = vault.takePeerToken(toUserId)
|
||||
|
||||
// Hand this contact OUR key on first contact so they can reply sealed.
|
||||
// Rides the ratchet as an ordinary content envelope, so the relay
|
||||
// cannot tell it from a photo (§14.4).
|
||||
val sealed = peerToken != null || peerKey != null
|
||||
|
||||
val preludes = listOfNotNull(
|
||||
ourDeliveryKeyEnvelopeFor(toUserId),
|
||||
ourKeyLogHeadEnvelopeFor(toUserId),
|
||||
@@ -528,7 +532,7 @@ class Conversations(
|
||||
for (prelude in preludes) {
|
||||
val pre = session.encrypt(prelude)
|
||||
outgoing += RelayClient.ToDevice(
|
||||
bundle.deviceId, wrap(pre, bundle, peerKey != null))
|
||||
bundle.deviceId, wrap(pre, bundle, sealed))
|
||||
}
|
||||
val envelope = session.encrypt(text)
|
||||
// Saved BEFORE the network call. If the relay accepts the
|
||||
@@ -538,7 +542,7 @@ class Conversations(
|
||||
// unused message key and nothing else.
|
||||
vault.saveSession(bundle.deviceId, session)
|
||||
outgoing += RelayClient.ToDevice(
|
||||
bundle.deviceId, wrap(envelope, bundle, peerKey != null))
|
||||
bundle.deviceId, wrap(envelope, bundle, sealed))
|
||||
delivered += bundle.deviceId
|
||||
} catch (e: Exception) {
|
||||
failed[bundle.deviceId] = e.message ?: e::class.simpleName.orEmpty()
|
||||
@@ -549,9 +553,33 @@ class Conversations(
|
||||
try {
|
||||
relay.sendEnvelopes(
|
||||
toUserId, outgoing, identity.deviceId,
|
||||
deliveryKey = peerKey?.let(Wire::b64),
|
||||
// ⚠️ The key only when there is no capability. Sending both
|
||||
// would let the relay accept on the key when the capability
|
||||
// had been revoked, so it would never learn it was refused
|
||||
// — and that is the whole lever §21 adds.
|
||||
deliveryKey = if (peerToken == null) peerKey?.let(Wire::b64) else null,
|
||||
deliveryToken = peerToken,
|
||||
)
|
||||
} catch (e: RelayException.Unauthorized) {
|
||||
// 🔴 **A refused capability means the batch was revoked.**
|
||||
// Retrying with the rest of it for ever is how a conversation
|
||||
// quietly stops working, so the batch goes and the SAME
|
||||
// envelopes are re-sent on the shared key — the sealed blobs
|
||||
// are reusable here because they are addressed to the device,
|
||||
// not to the authorisation.
|
||||
//
|
||||
// ⚠️ Not a way around revocation: revoking moves a sender back
|
||||
// onto §14.4's key, it does not silence them. A complete
|
||||
// cut-off is revoking the batch AND rotating the key — see
|
||||
// `revokeDeliveryTokens`.
|
||||
if (peerToken != null && peerKey != null) {
|
||||
vault.savePeerTokens(toUserId, emptyList())
|
||||
relay.sendEnvelopes(
|
||||
toUserId, outgoing, identity.deviceId,
|
||||
deliveryKey = peerKey.let(Wire::b64),
|
||||
)
|
||||
return Sent(delivered, failed, sealed = true)
|
||||
}
|
||||
// 🔴 **"Delivery key not accepted" — the cached key is stale.**
|
||||
// A contact who reinstalls rotates their delivery key, and a
|
||||
// send sealed with the old copy is refused with 401 for ever:
|
||||
@@ -585,7 +613,7 @@ class Conversations(
|
||||
return Sent(delivered, failed, sealed = false)
|
||||
}
|
||||
}
|
||||
return Sent(delivered, failed, sealed = peerKey != null)
|
||||
return Sent(delivered, failed, sealed = sealed)
|
||||
}
|
||||
|
||||
// -- Federation (§20.5) --------------------------------------------------
|
||||
@@ -1025,6 +1053,14 @@ class Conversations(
|
||||
// ...)` would poison user 0's delivery key and avatar, which is
|
||||
// every unattributed message on this device.
|
||||
if (senderProven && fromAddress == null) consumeControl(fromUser, content)
|
||||
// §21.4 — every message from a contact spent one of the
|
||||
// capabilities we issued them, so this is how the issuer knows
|
||||
// when to mint more without anybody asking. Counted for control
|
||||
// traffic too: a control message they sent cost one as well.
|
||||
if (senderProven && fromAddress == null
|
||||
&& vault.issuedTokens(fromUser).isNotEmpty()) {
|
||||
vault.noteReceivedSinceIssue(fromUser)
|
||||
}
|
||||
// 🔴 An UNSEALED message from a contact we have told our
|
||||
// delivery key means their copy is gone — a reinstall wiped
|
||||
// it. Sealing to us requires that key, and a peer who holds
|
||||
@@ -1130,9 +1166,100 @@ class Conversations(
|
||||
*/
|
||||
private fun ourDeliveryKeyEnvelopeFor(userId: Long): String? {
|
||||
val delivery = vault.loadDelivery() ?: return null
|
||||
if (!delivery.published || userId in delivery.toldPeers) return null
|
||||
if (!delivery.published) return null
|
||||
val needsKey = userId !in delivery.toldPeers
|
||||
val needsTokens = needsDeliveryTokens(userId)
|
||||
if (!needsKey && !needsTokens) return null
|
||||
|
||||
// §21.4 — a fresh batch of single-use capabilities rides the same
|
||||
// envelope as the key. Best-effort: a relay that refuses the upload
|
||||
// leaves this contact on the shared key, which still works, and failing
|
||||
// the send would turn an anti-abuse improvement into an outage.
|
||||
//
|
||||
// ⚠️ The key travels even when capabilities do. It is the fallback for
|
||||
// a sender holding none, and dropping it would cut off every contact
|
||||
// whose client predates this.
|
||||
val batch = if (needsTokens) {
|
||||
runCatching { issueDeliveryTokens(userId) }.getOrDefault(emptyList())
|
||||
} else emptyList()
|
||||
|
||||
vault.saveDelivery(delivery.copy(toldPeers = delivery.toldPeers + userId))
|
||||
return SealedSender.deliveryKeyEnvelope(delivery.key)
|
||||
return SealedSender.deliveryKeyEnvelope(delivery.key, batch)
|
||||
}
|
||||
|
||||
// -- §21.4 single-use delivery capabilities -------------------------------
|
||||
|
||||
/**
|
||||
* How many capabilities go to a contact in one batch, and the point at
|
||||
* which the issuer mints afresh.
|
||||
*
|
||||
* ⚠️ Re-issued at HALF rather than at the last one. The issuer counts what
|
||||
* it has received to know what the holder has spent, and that count is a
|
||||
* lower bound — sends that failed, or arrived while this device was
|
||||
* offline, are never seen — so it always runs somewhat behind. Half a batch
|
||||
* of slack costs nothing, and running dry anyway is safe: the sender falls
|
||||
* back to §14.4's shared key.
|
||||
*/
|
||||
private val deliveryTokenBatch = 64
|
||||
|
||||
/**
|
||||
* Mint a batch for one contact, publish the hashes, and return the
|
||||
* capabilities to hand over inside the ratchet.
|
||||
*
|
||||
* 🔴 **Only hashes reach the relay, and no row names a sender.** The map
|
||||
* from contact to batch stays in the vault because this device is the only
|
||||
* party that should hold it — a relay that knew which batch belonged to
|
||||
* whom would have exactly the per-sender pseudonym §21.2 rejects.
|
||||
*
|
||||
* ⚠️ Replaces the previous batch rather than adding to it, revoking the old
|
||||
* hashes in the same call. Otherwise a contact accumulates capabilities for
|
||||
* ever and cutting them off means remembering every batch ever issued.
|
||||
*/
|
||||
fun issueDeliveryTokens(toUserId: Long): List<String> {
|
||||
val random = java.security.SecureRandom()
|
||||
val fresh = (0 until deliveryTokenBatch).map {
|
||||
Wire.b64(ByteArray(32).also(random::nextBytes))
|
||||
}
|
||||
fun digest(list: List<String>) = list.map { Wire.b64(Wire.sha256(Wire.unb64(it))) }
|
||||
|
||||
relay.setDeliveryTokens(digest(fresh), digest(vault.issuedTokens(toUserId)))
|
||||
vault.saveIssuedTokens(toUserId, fresh)
|
||||
vault.resetReceivedSinceIssue(toUserId)
|
||||
return fresh
|
||||
}
|
||||
|
||||
/**
|
||||
* Cut one contact off, and nobody else.
|
||||
*
|
||||
* 🔴 **This is what §21 exists for.** With the shared key of §14.4 the same
|
||||
* act means rotating for everybody and redistributing to the rest, which is
|
||||
* why in practice it never happens.
|
||||
*
|
||||
* ⚠️ **This moves them back onto the shared key; it does not silence
|
||||
* them.** A complete cut-off is this AND [rotateDeliveryKey] — every other
|
||||
* contact recovers from that on its own now, because the new key rides the
|
||||
* same prelude a batch does. Two doors, and closing one does not close the
|
||||
* other.
|
||||
*/
|
||||
fun revokeDeliveryTokens(forUserId: Long): Int {
|
||||
val held = vault.issuedTokens(forUserId)
|
||||
if (held.isEmpty()) return 0
|
||||
relay.setDeliveryTokens(emptyList(), held.map { Wire.b64(Wire.sha256(Wire.unb64(it))) })
|
||||
vault.saveIssuedTokens(forUserId, emptyList())
|
||||
return held.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this contact is due a fresh batch.
|
||||
*
|
||||
* Counted rather than asked for: the holder cannot ask the relay how many
|
||||
* it has left, because the relay does not know whose are whose — but every
|
||||
* message we receive from somebody spent one of theirs.
|
||||
*/
|
||||
private fun needsDeliveryTokens(userId: Long): Boolean {
|
||||
val issued = vault.issuedTokens(userId)
|
||||
if (issued.isEmpty()) return true
|
||||
return vault.receivedSinceIssue(userId) >= issued.size / 2
|
||||
}
|
||||
|
||||
/** Take in the control messages this layer owns, rather than showing them. */
|
||||
@@ -1144,6 +1271,25 @@ class Conversations(
|
||||
internal fun consumeControl(fromUser: Long, content: Content) {
|
||||
when (content.kind) {
|
||||
Content.Kind.DELIVERY_KEY -> {
|
||||
// 🔴 **The batch is stored first, and independently.** Peer
|
||||
// delivery keys live inside our OWN delivery record, so the
|
||||
// original `loadDelivery() ?: return` dropped everything in
|
||||
// this envelope on a device that had not published a key of its
|
||||
// own yet — which is every device before `ensureDeliveryKey`
|
||||
// has run. Capabilities have their own store and no such
|
||||
// dependency, so they must not inherit that early return.
|
||||
//
|
||||
// ⚠️ Replaces rather than appends: the issuer revoked the
|
||||
// previous batch's hashes as it minted these, so anything still
|
||||
// held from before is already dead and spending it is refused.
|
||||
content.raw?.optJSONArray("tokens")?.let { array ->
|
||||
val fresh = (0 until array.length()).mapNotNull { i ->
|
||||
array.optString(i).takeIf {
|
||||
runCatching { Wire.unb64(it).size }.getOrNull() == 32
|
||||
}
|
||||
}
|
||||
vault.savePeerTokens(fromUser, fresh)
|
||||
}
|
||||
val key = content.raw?.optString("key")?.takeIf { it.isNotEmpty() } ?: return
|
||||
val delivery = vault.loadDelivery() ?: return
|
||||
vault.saveDelivery(
|
||||
|
||||
@@ -187,11 +187,21 @@ class RelayClient(
|
||||
envelopes: List<ToDevice>,
|
||||
fromDevice: String,
|
||||
deliveryKey: String? = null,
|
||||
/**
|
||||
* §21.4 — a single-use capability, spent on acceptance.
|
||||
*
|
||||
* ⚠️ **Sent instead of [deliveryKey], never alongside it.** Sending both
|
||||
* would let the relay accept on the key when the capability had been
|
||||
* revoked, so it would never learn the capability was refused — and
|
||||
* revocation is the whole lever §21 adds.
|
||||
*/
|
||||
deliveryToken: String? = null,
|
||||
): JSONObject =
|
||||
postJson("/e2e/messages", JSONObject()
|
||||
.put("to", toUser)
|
||||
.also { body ->
|
||||
if (deliveryKey != null) body.put("delivery_key", deliveryKey)
|
||||
if (deliveryToken != null) body.put("delivery_token", deliveryToken)
|
||||
else if (deliveryKey != null) body.put("delivery_key", deliveryKey)
|
||||
else body.put("from_device", fromDevice)
|
||||
}
|
||||
.put("messages", JSONArray().also { array ->
|
||||
@@ -271,6 +281,20 @@ class RelayClient(
|
||||
postJson("/e2e/delivery-key", JSONObject().put("digest", Wire.b64(digest)))
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /e2e/delivery-tokens` — add and drop single-use capabilities
|
||||
* (§21.4). Returns how many unspent ones this account now holds in total.
|
||||
*
|
||||
* ⚠️ One route with two lists, not two routes. Topping one contact up and
|
||||
* cutting another off are the same set difference to the relay, and
|
||||
* splitting them would let somebody watching request *shape* tell the two
|
||||
* apart without reading a byte of the body.
|
||||
*/
|
||||
fun setDeliveryTokens(add: List<String>, revoke: List<String>): Int =
|
||||
postJson("/e2e/delivery-tokens", JSONObject()
|
||||
.put("add", JSONArray(add))
|
||||
.put("revoke", JSONArray(revoke))).optInt("remaining", 0)
|
||||
|
||||
// -- Attachments ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -59,4 +59,11 @@ sealed class RelayException(message: String, cause: Throwable? = null) : Excepti
|
||||
internal object Wire {
|
||||
fun b64(data: ByteArray): String = java.util.Base64.getEncoder().encodeToString(data)
|
||||
fun unb64(text: String): ByteArray = java.util.Base64.getDecoder().decode(text)
|
||||
|
||||
/// The engine keeps its own `Bytes.sha256` `internal`, for the reason
|
||||
/// above: its public surface is the protocol, not its utilities. §21.4
|
||||
/// needs one here to hash a capability before uploading it, and SHA-256 has
|
||||
/// one right answer, so a second call site cannot drift.
|
||||
fun sha256(data: ByteArray): ByteArray =
|
||||
java.security.MessageDigest.getInstance("SHA-256").digest(data)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
package io.flamenet.client
|
||||
|
||||
import io.flamenet.e2e.DeviceIdentity
|
||||
import io.flamenet.e2e.E2ESession
|
||||
import io.flamenet.e2e.PeerBundle
|
||||
import io.flamenet.e2e.SealedSender
|
||||
import io.flamenet.e2e.Vault
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.json.JSONObject
|
||||
import javax.crypto.KeyGenerator
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Single-use delivery capabilities (§21.4).
|
||||
*
|
||||
* 🔴 **The property under test is that a capability is SPENT, not merely
|
||||
* checked.** One that survives its use is a reusable per-contact key, which is
|
||||
* a stable value the relay sees on every message from that sender — exactly the
|
||||
* pseudonym §21.2 rejects. So "the same one twice is refused" is not a
|
||||
* nice-to-have; it is the whole reason this is not per-contact delivery keys
|
||||
* with extra steps.
|
||||
*
|
||||
* The fixture keeps capability hashes in one flat set per recipient, with no
|
||||
* sender anywhere, because that is what the real relay stores. A stub that
|
||||
* indexed them by sender would let these tests pass on a property the relay
|
||||
* does not have.
|
||||
*/
|
||||
class DeliveryTokenTest {
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
val records = LinkedHashMap<String, ByteArray>()
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var vault: Vault
|
||||
private lateinit var me: DeviceIdentity
|
||||
private lateinit var peer: DeviceIdentity
|
||||
|
||||
/** What the relay holds, and what it has spent. */
|
||||
private val held = mutableSetOf<String>()
|
||||
private var spent = 0
|
||||
private var lastSend: JSONObject? = null
|
||||
/** Set to make the relay refuse the next capability, as a revocation does. */
|
||||
private var refuseTokens = false
|
||||
|
||||
@BeforeTest fun start() {
|
||||
me = DeviceIdentity.create()
|
||||
peer = DeviceIdentity.create()
|
||||
server = MockWebServer().also { it.dispatcher = relay(); it.start() }
|
||||
vault = Vault(MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
|
||||
vault.saveIdentity(me)
|
||||
}
|
||||
|
||||
@AfterTest fun stop() = server.shutdown()
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
.setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body)
|
||||
|
||||
private fun relay() = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path.orEmpty()
|
||||
return when {
|
||||
"/e2e/log/" in path -> MockResponse().setResponseCode(404)
|
||||
"/e2e/delivery-tokens" in path -> {
|
||||
val b = JSONObject(request.body.readUtf8())
|
||||
// Revoke first, so a client cutting one contact off while
|
||||
// topping another up in the same call keeps what it added.
|
||||
b.optJSONArray("revoke")?.let { a ->
|
||||
(0 until a.length()).forEach { held.remove(a.getString(it)) }
|
||||
}
|
||||
b.optJSONArray("add")?.let { a ->
|
||||
(0 until a.length()).forEach { held.add(a.getString(it)) }
|
||||
}
|
||||
json("""{"ok":true,"remaining":${held.size}}""")
|
||||
}
|
||||
"/e2e/delivery-key" in path -> json("""{"ok":true}""")
|
||||
"/e2e/messages" in path && request.method == "POST" -> {
|
||||
val b = JSONObject(request.body.readUtf8())
|
||||
lastSend = b
|
||||
val token = b.optString("delivery_token").ifEmpty { null }
|
||||
if (token != null) {
|
||||
val hash = Wire.b64(Wire.sha256(Wire.unb64(token)))
|
||||
if (refuseTokens || !held.contains(hash)) {
|
||||
return MockResponse().setResponseCode(401).setBody("{}")
|
||||
}
|
||||
held.remove(hash)
|
||||
spent++
|
||||
}
|
||||
json("""{"ok":true}""")
|
||||
}
|
||||
else -> json("""{"devices":[${peer.registrationWire()}]}""")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun conversations() =
|
||||
Conversations(me, vault, RelayClient(server.url("/").toString(), "me", "pw"))
|
||||
.also { it.myUserId = 5 }
|
||||
|
||||
/** Our own delivery key published, which is what carries a batch. */
|
||||
private fun withDeliveryKey(): Conversations {
|
||||
val c = conversations()
|
||||
c.ensureDeliveryKey()
|
||||
return c
|
||||
}
|
||||
|
||||
private fun myBundle(): PeerBundle {
|
||||
val opk = me.generateOneTimePreKeys(1).first()
|
||||
return PeerBundle.fromWire(me.registrationWire().put("opk",
|
||||
JSONObject().put("id", opk.id).put("pub", Wire.b64(opk.publicKey))))
|
||||
}
|
||||
|
||||
// MARK: - Issuing
|
||||
|
||||
/**
|
||||
* 🔴 The batch rides the EXISTING `delivery_key` envelope. A new control
|
||||
* kind would have arrived on shipped phones as a bubble full of raw JSON,
|
||||
* because they read the `ctl` flag with `optBoolean` and it does not coerce
|
||||
* the number the spec calls for.
|
||||
*/
|
||||
@Test
|
||||
fun `a batch travels with the delivery key, not as a kind of its own`() {
|
||||
val c = withDeliveryKey()
|
||||
c.send(toUserId = 42, text = "hello")
|
||||
|
||||
val envelope = JSONObject(SealedSender.deliveryKeyEnvelope(
|
||||
vault.loadDelivery()!!.key, vault.issuedTokens(42)))
|
||||
assertEquals("delivery_key", envelope.getString("kind"))
|
||||
assertEquals(64, envelope.getJSONArray("tokens").length())
|
||||
assertEquals(64, vault.issuedTokens(42).size)
|
||||
assertEquals(64, held.size, "the relay was given a hash per capability")
|
||||
}
|
||||
|
||||
/** Only hashes reach the relay, never a capability itself. */
|
||||
@Test
|
||||
fun `the relay is given hashes, never the capabilities`() {
|
||||
val c = withDeliveryKey()
|
||||
c.send(toUserId = 42, text = "hello")
|
||||
|
||||
val issued = vault.issuedTokens(42).toSet()
|
||||
assertTrue(held.none { it in issued }, "a raw capability was uploaded")
|
||||
assertTrue(held.all { Wire.unb64(it).size == 32 })
|
||||
}
|
||||
|
||||
// MARK: - Spending
|
||||
|
||||
@Test
|
||||
fun `a held capability is spent on a send`() {
|
||||
val c = withDeliveryKey()
|
||||
vault.savePeerTokens(42, listOf(Wire.b64(ByteArray(32) { 7 })))
|
||||
held.add(Wire.b64(Wire.sha256(ByteArray(32) { 7 })))
|
||||
|
||||
c.send(toUserId = 42, text = "sealed please")
|
||||
|
||||
assertEquals(1, spent)
|
||||
assertTrue(lastSend!!.optString("delivery_token").isNotEmpty())
|
||||
// ⚠️ And NOT the key alongside it. Sending both would let the relay
|
||||
// accept on the key when the capability had been revoked, so it would
|
||||
// never learn the capability was refused.
|
||||
assertTrue(lastSend!!.optString("delivery_key").isEmpty())
|
||||
assertTrue(vault.peerTokens(42).isEmpty(), "the capability was not spent locally")
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The load-bearing one. A capability that survived its use is a reusable
|
||||
* per-contact key, and the relay then sees one constant per sender.
|
||||
*/
|
||||
@Test
|
||||
fun `the same capability is not presented twice`() {
|
||||
val c = withDeliveryKey()
|
||||
val one = Wire.b64(ByteArray(32) { 7 })
|
||||
vault.savePeerTokens(42, listOf(one, Wire.b64(ByteArray(32) { 8 })))
|
||||
held.add(Wire.b64(Wire.sha256(Wire.unb64(one))))
|
||||
held.add(Wire.b64(Wire.sha256(ByteArray(32) { 8 })))
|
||||
|
||||
c.send(toUserId = 42, text = "one")
|
||||
val first = lastSend!!.getString("delivery_token")
|
||||
c.send(toUserId = 42, text = "two")
|
||||
val second = lastSend!!.getString("delivery_token")
|
||||
|
||||
assertFalse(first == second, "the same capability went out twice")
|
||||
assertEquals(2, spent)
|
||||
}
|
||||
|
||||
// MARK: - Revocation, which is the point
|
||||
|
||||
@Test
|
||||
fun `revoking drops exactly that contact's batch`() {
|
||||
val c = withDeliveryKey()
|
||||
c.send(toUserId = 42, text = "hello alice")
|
||||
c.send(toUserId = 43, text = "hello carol")
|
||||
assertEquals(128, held.size)
|
||||
|
||||
val dropped = c.revokeDeliveryTokens(forUserId = 42)
|
||||
|
||||
assertEquals(64, dropped)
|
||||
assertEquals(64, held.size, "the other contact's batch went too")
|
||||
assertTrue(vault.issuedTokens(42).isEmpty())
|
||||
assertEquals(64, vault.issuedTokens(43).size, "the other contact never noticed")
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ **Revoking moves a sender back onto the shared key; it does not
|
||||
* silence them.** Stated as a test because a client that implied otherwise
|
||||
* would be lying about the one thing this section is for.
|
||||
*/
|
||||
@Test
|
||||
fun `a refused capability falls back to the shared key rather than failing`() {
|
||||
val c = withDeliveryKey()
|
||||
vault.savePeerTokens(42, listOf(Wire.b64(ByteArray(32) { 7 })))
|
||||
vault.loadDelivery()!!.let {
|
||||
vault.saveDelivery(it.copy(peers = it.peers + (42L to ByteArray(32) { 9 })))
|
||||
}
|
||||
refuseTokens = true
|
||||
|
||||
val sent = c.send(toUserId = 42, text = "still there")
|
||||
|
||||
assertTrue(sent.reachedSomeone, "failed: ${sent.failed}")
|
||||
assertTrue(sent.sealed, "the fallback is still sealed, on the shared key")
|
||||
assertTrue(lastSend!!.optString("delivery_key").isNotEmpty())
|
||||
assertTrue(vault.peerTokens(42).isEmpty(), "a revoked batch was kept")
|
||||
}
|
||||
|
||||
// MARK: - Replenishment, counted rather than asked for
|
||||
|
||||
/**
|
||||
* 🔴 A "running low" control message would have been a new kind, with the
|
||||
* same rendering problem on shipped phones. It is not needed: the issuer
|
||||
* counts, because every message it receives from a contact spent one of
|
||||
* their capabilities.
|
||||
*/
|
||||
@Test
|
||||
fun `the issuer counts what it receives and mints again at half a batch`() {
|
||||
val c = withDeliveryKey()
|
||||
c.send(toUserId = 42, text = "first")
|
||||
val first = vault.issuedTokens(42).toSet()
|
||||
|
||||
// Their messages arrive; each one spent a capability we issued.
|
||||
repeat(32) { vault.noteReceivedSinceIssue(42) }
|
||||
c.send(toUserId = 42, text = "second")
|
||||
|
||||
val second = vault.issuedTokens(42).toSet()
|
||||
assertEquals(64, second.size)
|
||||
assertTrue(second.intersect(first).isEmpty(), "the batch was not replaced")
|
||||
assertEquals(64, held.size, "the old hashes were not revoked as the new were minted")
|
||||
assertEquals(0, vault.receivedSinceIssue(42), "the counter did not reset")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a batch is not reissued before half is spent`() {
|
||||
val c = withDeliveryKey()
|
||||
c.send(toUserId = 42, text = "first")
|
||||
val first = vault.issuedTokens(42).toSet()
|
||||
|
||||
repeat(10) { vault.noteReceivedSinceIssue(42) }
|
||||
c.send(toUserId = 42, text = "second")
|
||||
|
||||
assertEquals(first, vault.issuedTokens(42).toSet())
|
||||
}
|
||||
|
||||
// MARK: - Receiving a batch
|
||||
|
||||
@Test
|
||||
fun `a batch on an incoming delivery key is stored and never shown`() {
|
||||
val c = conversations()
|
||||
val fresh = (0 until 3).map { Wire.b64(ByteArray(32) { i -> (it + i).toByte() }) }
|
||||
val envelope = SealedSender.deliveryKeyEnvelope(ByteArray(32) { 4 }, fresh)
|
||||
|
||||
c.consumeControl(42, io.flamenet.e2e.Content.parse(envelope))
|
||||
|
||||
assertEquals(fresh, vault.peerTokens(42))
|
||||
assertFalse(io.flamenet.e2e.Content.parse(envelope).isRenderable,
|
||||
"a capability batch reached the thread")
|
||||
}
|
||||
|
||||
/** ⚠️ Malformed entries are dropped rather than stored: one that is not 32
|
||||
* bytes could never be spent, and keeping it would burn a send. */
|
||||
@Test
|
||||
fun `a malformed capability in a batch is dropped`() {
|
||||
val c = conversations()
|
||||
val good = Wire.b64(ByteArray(32) { 1 })
|
||||
val envelope = SealedSender.deliveryKeyEnvelope(
|
||||
ByteArray(32) { 4 }, listOf(good, "!!!!", Wire.b64(ByteArray(31))))
|
||||
|
||||
c.consumeControl(42, io.flamenet.e2e.Content.parse(envelope))
|
||||
|
||||
assertEquals(listOf(good), vault.peerTokens(42))
|
||||
}
|
||||
|
||||
/**
|
||||
* A contact who sends no batch leaves us on the shared key, which works.
|
||||
*
|
||||
* ⚠️ Needs our OWN key published, because a peer's key is stored inside our
|
||||
* delivery record. That is the app's real state — `ensureDeliveryKey` runs
|
||||
* at sign-in — and it is exactly why the capability store is separate and
|
||||
* does NOT depend on it.
|
||||
*/
|
||||
@Test
|
||||
fun `an envelope with no batch leaves the shared key alone`() {
|
||||
val c = withDeliveryKey()
|
||||
val envelope = SealedSender.deliveryKeyEnvelope(ByteArray(32) { 4 })
|
||||
|
||||
c.consumeControl(42, io.flamenet.e2e.Content.parse(envelope))
|
||||
|
||||
assertTrue(vault.peerTokens(42).isEmpty())
|
||||
assertEquals(32, vault.loadDelivery()?.peers?.get(42L)?.size)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user