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
@@ -0,0 +1,171 @@
package io.flamenet.e2e
import org.json.JSONObject
import java.security.SecureRandom
/**
* Sealed sender — spec §14.
*
* Moves the sender's identity *inside* the ciphertext, so the relay learns who
* a message is for and never who it is from.
*
* A transport feature rather than a protocol version: it changes what the
* server sees and nothing about how `SK` is derived, so it composes with v1,
* v2 and v3 unchanged.
*
* 🔴 **The seal is confidentiality, never authentication.** Anyone holding the
* recipient's public `IK_dh` — which the relay publishes to the world — can
* produce a well-formed seal claiming to be from anybody. The claimed sender is
* worth nothing until the *inner* envelope opens; the ratchet and the identity
* pin are what authenticate. Displaying, notifying on, or storing a claimed
* sender before that trades a metadata leak for free sender-spoofing, which is
* a worse product than the one being fixed.
*
* One-shot X25519 to the recipient device's published `IK_dh` rather than the
* ratchet, because a prekey message arrives before any session exists and so
* has no ratchet to use.
*/
object SealedSender {
private val INFO = "FlamenetE2E_SealedSender_v1".toByteArray(Charsets.UTF_8)
private val ZERO32 = ByteArray(32)
private const val NONCE_LEN = 12
private const val KEY_LEN = 32
private val random = SecureRandom()
/** A sealed blob, as it rides in the transport's `payload` field. */
data class Blob(val eph: ByteArray, val ciphertext: ByteArray) {
fun toJson(): JSONObject = JSONObject()
.put("s", 1)
.put("eph", Bytes.b64(eph))
.put("ct", Bytes.b64(ciphertext))
fun toPayload(): String =
Bytes.b64(toJson().toString().toByteArray(Charsets.UTF_8))
override fun equals(other: Any?): Boolean =
other is Blob && eph.contentEquals(other.eph) &&
ciphertext.contentEquals(other.ciphertext)
override fun hashCode(): Int = eph.contentHashCode() * 31 + ciphertext.contentHashCode()
}
/**
* What a seal carried, once opened.
*
* ⚠️ Both identity fields say `claimed` on purpose. Renaming them to
* something confident is all it would take for a caller to start believing
* them.
*/
data class Opened(
val claimedFromUser: Long,
val claimedFromDevice: String,
val envelope: Envelope,
)
/**
* Derive the one-shot key and nonce from a shared secret.
*
* The nonce comes out of the KDF rather than being transmitted, so there is
* nothing for a relay to grind on and no nonce to get wrong. Safe because
* the ephemeral key is fresh per seal: a repeated (key, nonce) pair under
* GCM leaks the authentication subkey, and the only way this repeats is if
* the same ephemeral is used twice.
*/
private fun derive(shared: ByteArray): Pair<ByteArray, ByteArray> {
val okm = Hkdf.derive(shared, ZERO32, INFO, KEY_LEN + NONCE_LEN)
return okm.copyOfRange(0, KEY_LEN) to okm.copyOfRange(KEY_LEN, KEY_LEN + NONCE_LEN)
}
/**
* Wrap an envelope so the relay cannot see who sent it.
*
* ⚠️ The AAD binds the **destination device** and the ephemeral key, which
* is what stops a relay taking one person's sealed blob and dropping it
* into somebody else's inbox: the copy would not open there.
*/
fun seal(
envelope: Envelope,
fromUser: Long,
fromDevice: String,
toDevice: String,
recipientIKdh: ByteArray,
): Blob {
val eph = X25519.generate()
val ephPub = X25519.rawFromPublicKey(eph.public)
val (key, nonce) = derive(X25519.agree(eph.private, recipientIKdh))
val inner = JSONObject()
.put("from_user", fromUser)
.put("from_device", fromDevice)
.put("envelope", envelope.toJson())
.toString()
.toByteArray(Charsets.UTF_8)
val aad = Bytes.concat(toDevice.toByteArray(Charsets.UTF_8), ephPub)
// `Aead.seal` returns `nonce || ct || tag`; the wire carries only
// `ct || tag`, because the nonce is derived on both sides.
val box = Aead.seal(key, nonce, inner, aad)
return Blob(ephPub, box.copyOfRange(NONCE_LEN, box.size))
}
/**
* Open a sealed blob with this device's `IK_dh`.
*
* See the type-level warning: what comes back is a routing hint, not a fact.
*/
fun open(blob: Blob, myIKdhPrivate: java.security.PrivateKey, myDeviceId: String): Opened {
val (key, nonce) = derive(X25519.agree(myIKdhPrivate, blob.eph))
val aad = Bytes.concat(myDeviceId.toByteArray(Charsets.UTF_8), blob.eph)
val plain = Aead.open(key, Bytes.concat(nonce, blob.ciphertext), aad)
val inner = JSONObject(String(plain, Charsets.UTF_8))
return Opened(
claimedFromUser = inner.optLong("from_user"),
claimedFromDevice = inner.optString("from_device"),
envelope = Envelope.fromJson(inner.getJSONObject("envelope")),
)
}
/**
* Whether a transport payload is a sealed blob rather than a bare envelope.
*
* Both shapes arrive in the same field, so this is what decides which
* decoder to use. Deliberately structural — `s == 1` and an `eph` string —
* rather than a guess based on which parse throws first.
*/
fun isSealed(payload: String): Boolean = try {
val json = JSONObject(String(Bytes.unb64(payload), Charsets.UTF_8))
json.optInt("s") == 1 && json.optString("eph").isNotEmpty()
} catch (_: Exception) {
false
}
fun blobFromPayload(payload: String): Blob {
val json = JSONObject(String(Bytes.unb64(payload), Charsets.UTF_8))
require(json.optInt("s") == 1) { "unsupported seal version ${json.optInt("s")}" }
return Blob(Bytes.unb64(json.getString("eph")), Bytes.unb64(json.getString("ct")))
}
/**
* The 32-byte capability that authorises an unauthenticated sealed
* submission to a given user (§14.4).
*
* ⚠️ **The server stores only the digest.** The key itself reaches contacts
* inside E2E messages, so the relay never sees it from its owner — which is
* the only reason a sealed submission can be authorised without naming who
* is submitting.
*
* Rotating it is the only way to cut off a sealed spammer, and it costs a
* redistribution to every contact. That is the whole trade §14.4 makes:
* anonymity for the sender, and a blunt instrument for the recipient.
*/
fun newDeliveryKey(): ByteArray = ByteArray(32).also(random::nextBytes)
/** What the server stores. Never send the key itself. */
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)}"}"""
}
@@ -49,6 +49,7 @@ 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 DELIVERY_KEY = "delivery-key"
/** The two kinds of thread, named once so they cannot drift apart. */
fun threadForUser(userId: Long) = "user:$userId"
@@ -210,6 +211,61 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
fun forgetTrust(userId: Long) = storage.delete(trustName(userId))
// -- Sealed sender (§14.4) -----------------------------------------------
/**
* This account's delivery key, and whether the relay has its digest yet.
*
* 🔴 **The key itself never goes to the relay — only `SHA-256(key)` does.**
* It reaches contacts inside E2E messages, so the relay never sees it from
* its owner, and that is the only reason an unauthenticated sealed
* submission can be authorised without naming who is submitting. Sending
* the raw key to the server would make the whole mechanism decorative.
*
* `published` tracks the digest upload separately from the key, because a
* key generated and never published is one every contact would try to seal
* to and every seal would be rejected.
*/
data class Delivery(
val key: ByteArray,
val published: Boolean,
/** Peer user id -> their delivery key, so we can reach them sealed. */
val peers: Map<Long, ByteArray> = emptyMap(),
/** Peers we have already handed OUR key to, so we send it once. */
val toldPeers: Set<Long> = emptySet(),
) {
override fun equals(other: Any?): Boolean =
other is Delivery && key.contentEquals(other.key) && published == other.published &&
peers.keys == other.peers.keys && toldPeers == other.toldPeers
override fun hashCode(): Int = key.contentHashCode() * 31 + published.hashCode()
}
fun loadDelivery(): Delivery? = readJson(DELIVERY_KEY)?.let { o ->
val peers = o.optJSONObject("peers") ?: JSONObject()
val told = o.optJSONArray("told") ?: org.json.JSONArray()
Delivery(
key = java.util.Base64.getDecoder().decode(o.getString("key")),
published = o.optBoolean("published", false),
peers = peers.keys().asSequence().associate { k ->
k.toLong() to java.util.Base64.getDecoder().decode(peers.getString(k))
},
toldPeers = (0 until told.length()).map { told.getLong(it) }.toSet(),
)
}
fun saveDelivery(delivery: Delivery) {
val peers = JSONObject()
delivery.peers.forEach { (id, k) ->
peers.put(id.toString(), java.util.Base64.getEncoder().encodeToString(k))
}
writeJson(DELIVERY_KEY, JSONObject()
.put("key", java.util.Base64.getEncoder().encodeToString(delivery.key))
.put("published", delivery.published)
.put("peers", peers)
.put("told", org.json.JSONArray(delivery.toldPeers.toList())))
}
// -- Key-log pin (§19.3) -------------------------------------------------
/**