Android: sealed sender, so the relay stops learning who talks to whom

Key transparency stopped the relay substituting keys. It did nothing about the
relay watching who writes to whom, which is the metadata most people actually
leak. §14 moves the sender's identity inside the ciphertext: the relay learns
who a message is for and nothing about who it is from.

Verified against relay1 rather than asserted. A sealed submission is accepted
unauthenticated, the stored row comes back with `from_user = 0` — the relay
saying it does not know — and the recipient still reads the message with the
sender recovered from inside the seal.

Cross-engine verified in BOTH directions against the normative web engine, and
that mattered: the seal is asymmetric in practice, since one side derives from
an ephemeral it generated and the other from one it was handed, so a byte-order
or AAD mistake can easily break only one direction and a single-direction test
picks the half that works. The interop harness gained `seal` and `open_sealed`
ops for it; 43 assertions, none failing.

🔴 **The seal is confidentiality, never authentication.** The recipient's
`IK_dh` is published to the world, so anyone can produce a well-formed seal
claiming to be from anybody. The claimed sender picks a session and nothing
else; the ratchet opening it is what makes it a fact, and the identity pin is
what refuses an impostor. There is a test that forges a sender deliberately, so
that if somebody later renames these fields into something confident the reason
they are claims is still written down and still executing.

⚠️ **The first message to a new contact cannot be sealed.** Sealing needs the
recipient's delivery key and that key travels inside an E2E message, so a prior
exchange is required. `Sent.sealed` reports which happened rather than leaving
the UI free to imply anonymity that was not achieved — a silent fall back to an
unsealed send is exactly what §14.5 warns about.

⚠️ **Rotation is blunt, and the copy says so.** There is no sender to block, so
the only lever is replacing the delivery key, which cuts off every contact
until the next time they are written to. Reachable from Account, asserted by
`CrudSurfaceTest`, and live-tested: a submission presenting a retired key is
refused by the relay.

The relay stores only `SHA-256(key)`. The key itself never passes through the
relay from its owner, which is the only reason an unauthenticated sealed
submission can be authorised without naming who is submitting.

Tested: 108 engine, 67 client, 43 interop, none failing, live suites enabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-25 14:31:07 -04:00
co-authored by Claude Opus 5
parent cfeefa0616
commit a213ad8d5d
11 changed files with 832 additions and 17 deletions
@@ -6,6 +6,7 @@ import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.E2ESession
import io.flamenet.e2e.Envelope
import io.flamenet.e2e.PeerBundle
import io.flamenet.e2e.SealedSender
import io.flamenet.e2e.Vault
/**
@@ -42,6 +43,15 @@ class Conversations(
val fromUser: Long,
val fromDevice: String,
val content: Content,
/**
* Whether this arrived sealed (§14).
*
* ⚠️ For a sealed message [fromUser] and [fromDevice] come from inside
* the blob, and are trustworthy **only because the inner envelope
* decrypted** under a session those identities selected. Never surface
* a sealed message's sender on a path that skips that.
*/
val sealed: Boolean = false,
) {
val text: String get() = content.text
}
@@ -57,6 +67,16 @@ class Conversations(
* be absurd.
*/
val attachment: Attachment.Ref? = null,
/**
* Whether the relay was told who sent this (§14).
*
* Reported rather than assumed: sealing needs the recipient's delivery
* key, which arrives inside an E2E message, so the first exchange with
* a new contact is necessarily unsealed. A UI that implied anonymity
* for that message would be lying about the one thing this feature is
* for.
*/
val sealed: Boolean = false,
) {
val reachedSomeone: Boolean get() = delivered.isNotEmpty()
}
@@ -190,10 +210,26 @@ class Conversations(
val failed = LinkedHashMap<String, String>()
val outgoing = mutableListOf<RelayClient.ToDevice>()
// 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.
val peerKey = deliveryKeyFor(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 prelude = ourDeliveryKeyEnvelopeFor(toUserId)
for (bundle in bundles) {
if (bundle.deviceId == identity.deviceId) continue // never talk to ourselves
try {
val session = sessionFor(bundle)
if (prelude != null) {
val pre = session.encrypt(prelude)
outgoing += RelayClient.ToDevice(
bundle.deviceId, wrap(pre, bundle, peerKey != null))
}
val envelope = session.encrypt(text)
// Saved BEFORE the network call. If the relay accepts the
// envelope and we then crash, the peer has a message our
@@ -201,15 +237,21 @@ class Conversations(
// do recovers it. The reverse — saved, not sent — costs one
// unused message key and nothing else.
vault.saveSession(bundle.deviceId, session)
outgoing += RelayClient.ToDevice(bundle.deviceId, envelope)
outgoing += RelayClient.ToDevice(
bundle.deviceId, wrap(envelope, bundle, peerKey != null))
delivered += bundle.deviceId
} catch (e: Exception) {
failed[bundle.deviceId] = e.message ?: e::class.simpleName.orEmpty()
}
}
if (outgoing.isNotEmpty()) relay.sendEnvelopes(toUserId, outgoing, identity.deviceId)
return Sent(delivered, failed)
if (outgoing.isNotEmpty()) {
relay.sendEnvelopes(
toUserId, outgoing, identity.deviceId,
deliveryKey = peerKey?.let(Wire::b64),
)
}
return Sent(delivered, failed, sealed = peerKey != null)
}
/**
@@ -231,15 +273,33 @@ class Conversations(
for (message in incoming) {
try {
val envelope = Envelope.fromPayload(message.payload)
val session = existingSession(message.fromDevice)
// 🔴 A sealed message arrives with NO sender the relay could
// vouch for — the transport's from_user/from_device are absent
// or meaningless. The blob names a sender, and anyone holding
// this device's published IK_dh can write whatever they like
// there. So the claim is used ONLY to pick a session, and it is
// the ratchet opening that decides who this actually was.
val sealed = SealedSender.isSealed(message.payload)
val opened = if (sealed) {
SealedSender.open(
SealedSender.blobFromPayload(message.payload),
identity.identityDH.private,
identity.deviceId,
)
} else null
val envelope = opened?.envelope ?: Envelope.fromPayload(message.payload)
val fromDevice = opened?.claimedFromDevice ?: message.fromDevice
val session = existingSession(fromDevice)
?: E2ESession.respond(identity, envelope)
// Nothing below this line runs unless the ratchet opened it,
// which is what turns the claim above into a fact.
val plaintext = session.decrypt(envelope)
vault.saveSession(message.fromDevice, session)
out += Received(
message.id, message.fromUser, message.fromDevice,
Content.parse(plaintext),
)
vault.saveSession(fromDevice, session)
val fromUser = opened?.claimedFromUser ?: message.fromUser
val content = Content.parse(plaintext)
consumeControl(fromUser, content)
out += Received(message.id, fromUser, fromDevice, content, sealed = sealed)
} catch (_: Exception) {
// Deliberately swallowed and skipped. One undecryptable message
// is not a reason to stop reading the inbox.
@@ -265,6 +325,84 @@ class Conversations(
/** Set once known; only [replenishPreKeysIfLow] needs it. */
var myUserId: Long? = null
// -- Sealed sender (§14) -------------------------------------------------
/** Wrap an envelope for one device, sealed when we can reach them that way. */
private fun wrap(envelope: Envelope, bundle: PeerBundle, sealed: Boolean): String =
if (!sealed) envelope.toPayload()
else SealedSender.seal(
envelope = envelope,
fromUser = myUserId ?: 0L,
fromDevice = identity.deviceId,
toDevice = bundle.deviceId,
recipientIKdh = bundle.identityDH,
).toPayload()
private fun deliveryKeyFor(userId: Long): ByteArray? =
vault.loadDelivery()?.peers?.get(userId)
/**
* Our delivery key as a control envelope, the first time we write to
* someone — and null every time after.
*
* Marked as told BEFORE the send rather than after: a resend that repeats
* it costs a wasted envelope, while a crash between sending and marking
* would repeat it on every message for ever.
*/
private fun ourDeliveryKeyEnvelopeFor(userId: Long): String? {
val delivery = vault.loadDelivery() ?: return null
if (!delivery.published || userId in delivery.toldPeers) return null
vault.saveDelivery(delivery.copy(toldPeers = delivery.toldPeers + userId))
return SealedSender.deliveryKeyEnvelope(delivery.key)
}
/** Take in the control messages this layer owns, rather than showing them. */
private fun consumeControl(fromUser: Long, content: Content) {
when (content.kind) {
Content.Kind.DELIVERY_KEY -> {
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))))
}
Content.Kind.KEY_LOG_HEAD -> {
val raw = content.raw ?: return
trust.gossip(raw.optInt("size"), raw.optString("root"))
}
else -> Unit
}
}
/**
* Make sure this account has a delivery key and the relay has its digest.
*
* ⚠️ Publishing the DIGEST, never the key. Sending the key itself would let
* the relay seal to its own users and would make rotation meaningless.
*/
fun ensureDeliveryKey() {
val existing = vault.loadDelivery()
if (existing != null && existing.published) return
val delivery = existing ?: Vault.Delivery(SealedSender.newDeliveryKey(), published = false)
relay.setDeliveryKey(SealedSender.deliveryKeyDigest(delivery.key))
vault.saveDelivery(delivery.copy(published = true))
}
/**
* Replace the delivery key, cutting off anyone who has the old one.
*
* ⚠️ **This is the only way to stop a sealed spammer, and it is blunt.**
* Every contact holding the old key loses the ability to reach us sealed
* until they are told the new one, which happens the next time we write to
* them. That is the trade §14.4 makes: anonymity for senders costs the
* recipient a per-contact redistribution.
*/
fun rotateDeliveryKey() {
val fresh = Vault.Delivery(SealedSender.newDeliveryKey(), published = false)
relay.setDeliveryKey(SealedSender.deliveryKeyDigest(fresh.key))
// `toldPeers` deliberately cleared: everyone has to be told again.
vault.saveDelivery(fresh.copy(published = true))
}
private fun existingSession(peerDeviceId: String): E2ESession? =
runCatching { vault.loadSession(peerDeviceId) }.getOrNull()
@@ -123,10 +123,29 @@ class RelayClient(
* malformed body, which at least fails loudly — the shape being wrong in a
* way the relay tolerates is the version that costs a week.
*/
fun sendEnvelopes(toUser: Long, envelopes: List<ToDevice>, fromDevice: String): JSONObject =
/**
* @param deliveryKey the recipient's capability, base64. When present the
* submission is **sealed**: the relay is not told who is sending, and
* `fromDevice` is omitted entirely rather than sent as a courtesy.
*
* 🔴 **Omitting `from_device` is the whole point, not tidiness.** Sending it
* alongside a sealed payload would hand the relay the sender's identity at
* request time, so the seal would hide the sender from the stored row and
* from nobody else — strictly worse than not sealing, because it looks like
* it worked.
*/
fun sendEnvelopes(
toUser: Long,
envelopes: List<ToDevice>,
fromDevice: String,
deliveryKey: String? = null,
): JSONObject =
postJson("/e2e/messages", JSONObject()
.put("to", toUser)
.put("from_device", fromDevice)
.also { body ->
if (deliveryKey != null) body.put("delivery_key", deliveryKey)
else body.put("from_device", fromDevice)
}
.put("messages", JSONArray().also { array ->
envelopes.forEach {
array.put(JSONObject()
@@ -0,0 +1,146 @@
package io.flamenet.client
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.SealedSender
import io.flamenet.e2e.Vault
import javax.crypto.KeyGenerator
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
/**
* Sealed sender (§14) against a relay that is actually running.
*
* ⚠️ Opt-in via `FN_LIVE_FLEET`, pointed at the disposable fleet.
*
* FN_LIVE_FLEET=https://relay1.flamenet.io ./gradlew :client:test
*
* The engine tests prove the seal is sound. What only a real relay can show is
* that the relay ACCEPTS an unauthenticated sealed submission, that it stores
* no sender against it, and that the recipient still gets the message — which
* is the entire claim the feature makes.
*/
class LiveSealedSenderTest {
private val base: String? = System.getenv("FN_LIVE_FLEET")
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 class Actor(base: String, tag: String) {
val handle = "sl$tag${System.currentTimeMillis() % 100_000_000}"
val account = RelayAccounts(base).signUp(
username = handle,
email = "$handle@example.invalid",
password = "a-long-enough-passphrase",
)
val identity: DeviceIdentity = DeviceIdentity.create()
val vault = Vault(
MapStorage(),
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(),
)
val relay = RelayClient(base, username = account.username, appPassword = account.appPassword)
val userId: Long get() = account.userId
val conversations = Conversations(identity, vault, relay).also { it.myUserId = userId }
init {
relay.useRelayAuth(identity)
relay.registerDevice(identity, identity.generateOneTimePreKeys(5))
}
}
/**
* 🔴 The claim, end to end: the relay carries the message and does not
* learn who sent it.
*/
@Test
fun `a sealed message arrives and the relay records no sender`() {
val url = base ?: return
val alice = Actor(url, "a")
val bob = Actor(url, "b")
bob.conversations.ensureDeliveryKey()
alice.conversations.ensureDeliveryKey()
// First contact is necessarily UNSEALED — Alice does not hold Bob's
// delivery key yet, and the key arrives inside an E2E message. This is
// the honest shape of the feature, not a gap: sealing needs a prior
// exchange, and a client that claimed otherwise would be lying.
val first = alice.conversations.send(bob.userId, "hello, unsealed")
assertTrue(first.reachedSomeone)
assertFalse(first.sealed, "the first message to a new contact cannot be sealed")
// Bob reads it and, with it, learns nothing yet — Alice has not been
// told Bob's key. Bob replying is what hands Alice his key.
val bobRead = bob.conversations.poll()
assertTrue(bobRead.any { it.content.text == "hello, unsealed" })
val reply = bob.conversations.send(alice.userId, "hello back")
assertTrue(reply.reachedSomeone)
// Alice picks up Bob's delivery key from the control envelope that rode
// along with his reply.
alice.conversations.poll()
// Now she can seal.
val sealed = alice.conversations.send(bob.userId, "this one is sealed")
assertTrue(sealed.sealed, "Alice holds Bob's delivery key and must seal")
assertTrue(sealed.reachedSomeone)
// 🔴 The relay stored no sender for it. `from_user` 0 is the relay
// saying it does not know — which is the whole feature.
val inbox = bob.relay.pollInbox(bob.identity.deviceId, 0, 100)
val sealedRow = inbox.last()
assertEquals(0L, sealedRow.fromUser, "the relay must not know who sent a sealed message")
assertNotEquals(alice.identity.deviceId, sealedRow.fromDevice)
assertTrue(SealedSender.isSealed(sealedRow.payload), "the payload should be a seal")
// And Bob still reads it, with the sender recovered from inside.
val got = bob.conversations.poll().single { it.content.text == "this one is sealed" }
assertTrue(got.sealed)
assertEquals(alice.userId, got.fromUser)
assertEquals(alice.identity.deviceId, got.fromDevice)
}
/**
* Rotation cuts off the old key, which is the only lever §14.4 offers
* against a sealed spammer.
*/
@Test
fun `rotating the delivery key refuses submissions holding the old one`() {
val url = base ?: return
val alice = Actor(url, "c")
val bob = Actor(url, "d")
bob.conversations.ensureDeliveryKey()
val old = bob.vault.loadDelivery()!!.key
// Bob replaces it. The relay now holds a digest the old key misses.
bob.conversations.rotateDeliveryKey()
assertFalse(bob.vault.loadDelivery()!!.key.contentEquals(old))
// A submission presenting the OLD key must be refused outright. Sent
// through the client rather than by hand, so this exercises the same
// path a real sealed send takes.
val bundle = alice.relay.fetchKeys(bob.userId).first { it.deviceId == bob.identity.deviceId }
val envelope = io.flamenet.e2e.E2ESession.initiate(alice.identity, bundle).encrypt("stale")
val blob = SealedSender.seal(
envelope, alice.userId, alice.identity.deviceId, bundle.deviceId, bundle.identityDH)
val refused = runCatching {
alice.relay.sendEnvelopes(
bob.userId,
listOf(RelayClient.ToDevice(bundle.deviceId, blob.toPayload())),
alice.identity.deviceId,
deliveryKey = Wire.b64(old),
)
}
assertTrue(refused.isFailure, "the relay must refuse a retired delivery key")
}
}