diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 4f110e2..8779294 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -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 { + val random = java.security.SecureRandom() + val fresh = (0 until deliveryTokenBatch).map { + Wire.b64(ByteArray(32).also(random::nextBytes)) + } + fun digest(list: List) = 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( diff --git a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt index 1559476..cc93a67 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt @@ -187,11 +187,21 @@ class RelayClient( envelopes: List, 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, revoke: List): Int = + postJson("/e2e/delivery-tokens", JSONObject() + .put("add", JSONArray(add)) + .put("revoke", JSONArray(revoke))).optInt("remaining", 0) + // -- Attachments --------------------------------------------------------- /** diff --git a/client/src/main/kotlin/io/flamenet/client/RelayException.kt b/client/src/main/kotlin/io/flamenet/client/RelayException.kt index 3e8a325..8bf6712 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayException.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayException.kt @@ -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) } diff --git a/client/src/test/kotlin/io/flamenet/client/DeliveryTokenTest.kt b/client/src/test/kotlin/io/flamenet/client/DeliveryTokenTest.kt new file mode 100644 index 0000000..a2980df --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/DeliveryTokenTest.kt @@ -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() + 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() + 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) + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/SealedSender.kt b/engine/src/main/kotlin/io/flamenet/e2e/SealedSender.kt index fc2c475..ee8de3a 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/SealedSender.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/SealedSender.kt @@ -189,6 +189,27 @@ object SealedSender { fun deliveryKeyDigest(key: ByteArray): ByteArray = Bytes.sha256(key) /** The control message that hands a contact this key (§6.6, §14.4). */ - fun deliveryKeyEnvelope(key: ByteArray): String = - """{"fnc":1,"kind":"${Content.Kind.DELIVERY_KEY}","key":"${Bytes.b64(key)}"}""" + /** + * The control message handing a contact our delivery key (§14.4) and, since + * §21.4, a batch of single-use capabilities alongside it. + * + * 🔴 **One envelope, not two, and that is a compatibility decision rather + * than tidiness.** A receiver recognises control traffic by KIND first and + * falls back to the `ctl` flag only for kinds it has never heard of — and + * the build in the field reads that flag with `optBoolean`, which does not + * coerce the number §6.10 specifies. A new `delivery_tokens` kind would + * therefore have arrived on those phones as a bubble full of raw JSON, + * which is the bug this engine fixed in the other direction on 2026-08-30. + * + * ⚠️ Hand-assembled so `fnc` stays the first key: the marker is a prefix + * test, and a reordered top level decodes as a plain text message — raw + * JSON in somebody's conversation. + */ + @JvmOverloads + fun deliveryKeyEnvelope(key: ByteArray, tokens: List = emptyList()): String { + val head = """{"fnc":1,"kind":"${Content.Kind.DELIVERY_KEY}","key":"${Bytes.b64(key)}"""" + if (tokens.isEmpty()) return "$head}" + return head + ""","tokens":[""" + + tokens.joinToString(",") { "\"" + it + "\"" } + "]}" + } } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 2a37fe8..638ae72 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -55,6 +55,9 @@ class Vault(private val storage: Storage, private val key: SecretKey) { private const val HISTORY_PREFIX = "history/" private const val TRUST_PREFIX = "trust/" private const val KEYLOG_PIN = "keylog-pin" + private const val ISSUED_TOKENS = "issued-tokens-" + private const val PEER_TOKENS = "peer-tokens-" + private const val RECEIVED_SINCE = "tokens-received-" private const val SEEN_ENVELOPES = "seen-envelopes" private const val EXTRA_HOMES = "extra-homes" private const val MAX_SEEN_ENVELOPES = 2000 @@ -586,6 +589,72 @@ class Vault(private val storage: Storage, private val key: SecretKey) { get() = readJson(WATERMARK)?.optLong("id", 0) ?: 0 set(value) = writeJson(WATERMARK, JSONObject().put("id", value)) + // -- §21.4 single-use delivery capabilities ------------------------------- + + /** + * Capabilities we ISSUED, by contact. + * + * 🔴 **This map is the whole revocation mechanism**, and it lives here + * rather than on the relay because the relay must not be able to tell + * senders apart — it holds hashes and cannot say whose they are. Losing it + * is losing the ability to cut one contact off. + */ + fun issuedTokens(userId: Long): List = tokenList(ISSUED_TOKENS, userId) + + fun saveIssuedTokens(userId: Long, tokens: List) = + saveTokenList(ISSUED_TOKENS, userId, tokens) + + /** Capabilities a contact issued US. Spent one per sealed message. */ + fun peerTokens(userId: Long): List = tokenList(PEER_TOKENS, userId) + + fun savePeerTokens(userId: Long, tokens: List) = + saveTokenList(PEER_TOKENS, userId, tokens) + + /** + * Take the next capability for a contact, removing it. + * + * ⚠️ Removed as it is taken, before the send is attempted. One reused after + * a failed send may already have been spent at the relay, and presenting it + * again is refused — so a lost send costs one capability rather than a + * conversation that quietly stops working. + */ + fun takePeerToken(userId: Long): String? { + val held = peerTokens(userId).toMutableList() + if (held.isEmpty()) return null + val next = held.removeAt(0) + savePeerTokens(userId, held) + return next + } + + /** + * Messages taken from a contact since they were issued a batch. + * + * 🔴 **This is how replenishment works without anybody asking.** The holder + * cannot ask the relay how many it has left — the relay does not know whose + * are whose — but the issuer can count, because every message it receives + * from somebody spent one of theirs. + */ + fun receivedSinceIssue(userId: Long): Int = + readJson(RECEIVED_SINCE + userId)?.optInt("n", 0) ?: 0 + + fun noteReceivedSinceIssue(userId: Long) = + writeJson(RECEIVED_SINCE + userId, + JSONObject().put("n", receivedSinceIssue(userId) + 1)) + + fun resetReceivedSinceIssue(userId: Long) = + writeJson(RECEIVED_SINCE + userId, JSONObject().put("n", 0)) + + private fun tokenList(prefix: String, userId: Long): List = + readJson(prefix + userId)?.optJSONArray("t")?.let { a -> + (0 until a.length()).map { a.getString(it) } + } ?: emptyList() + + private fun saveTokenList(prefix: String, userId: Long, tokens: List) { + if (tokens.isEmpty()) storage.delete(prefix + userId) + else writeJson(prefix + userId, + JSONObject().put("t", org.json.JSONArray(tokens))) + } + // -- Extra mailboxes (§20.4) --------------------------------------------- /**