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:
Paul Hitt
2026-08-30 19:25:22 -04:00
co-authored by Claude Opus 4.8
parent 356c89e1cd
commit 79c2734f86
6 changed files with 601 additions and 13 deletions
@@ -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)
}
}