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:
co-authored by
Claude Opus 5
parent
cfeefa0616
commit
a213ad8d5d
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user