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
@@ -0,0 +1,171 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Sealed sender (§14), and mostly the things it must refuse.
|
||||
*
|
||||
* A seal that opens for the right person is easy. The properties worth testing
|
||||
* are that it does NOT open for anyone else, and that the identity it carries
|
||||
* is treated as a claim.
|
||||
*/
|
||||
class SealedSenderTest {
|
||||
|
||||
private fun pair(): Pair<DeviceIdentity, DeviceIdentity> =
|
||||
DeviceIdentity.create() to DeviceIdentity.create()
|
||||
|
||||
/** A real envelope, so the test is not sealing a string. */
|
||||
private fun envelope(from: DeviceIdentity, to: DeviceIdentity, text: String): Envelope {
|
||||
val oneTime = to.generateOneTimePreKeys(1).first()
|
||||
val wire = to.registrationWire()
|
||||
.put("opk", org.json.JSONObject().put("id", oneTime.id).put("pub", Bytes.b64(oneTime.publicKey)))
|
||||
return E2ESession.initiate(from, PeerBundle.fromWire(wire)).encrypt(text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a seal opens for the device it was addressed to`() {
|
||||
val (alice, bob) = pair()
|
||||
val env = envelope(alice, bob, "hello")
|
||||
|
||||
val blob = SealedSender.seal(env, 7, alice.deviceId, bob.deviceId, bob.identityDHPublic)
|
||||
val opened = SealedSender.open(blob, bob.identityDH.private, bob.deviceId)
|
||||
|
||||
assertEquals(7, opened.claimedFromUser)
|
||||
assertEquals(alice.deviceId, opened.claimedFromDevice)
|
||||
assertEquals(env.toPayload(), opened.envelope.toPayload())
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The AAD binds the destination device.
|
||||
*
|
||||
* Without this a relay could copy one person's sealed blob into somebody
|
||||
* else's inbox and it would decrypt there — turning a privacy feature into
|
||||
* a delivery bug that leaks messages to the wrong account.
|
||||
*/
|
||||
@Test
|
||||
fun `a seal does not open on a device it was not addressed to`() {
|
||||
val (alice, bob) = pair()
|
||||
val eve = DeviceIdentity.create()
|
||||
val blob = SealedSender.seal(
|
||||
envelope(alice, bob, "hello"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic)
|
||||
|
||||
// Even holding a valid private key, and even being handed the blob.
|
||||
assertFailsWith<Aead.DecryptionFailed> {
|
||||
SealedSender.open(blob, eve.identityDH.private, eve.deviceId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Bob's own key, but the AAD says another device id.
|
||||
*
|
||||
* Separated from the test above so the two failure reasons cannot be
|
||||
* confused: this one proves the device id is in the AAD, not merely that a
|
||||
* different DH key fails.
|
||||
*/
|
||||
@Test
|
||||
fun `the destination device id is part of what is authenticated`() {
|
||||
val (alice, bob) = pair()
|
||||
val blob = SealedSender.seal(
|
||||
envelope(alice, bob, "hello"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic)
|
||||
|
||||
assertFailsWith<Aead.DecryptionFailed> {
|
||||
SealedSender.open(blob, bob.identityDH.private, "some-other-device-id")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tampered ciphertext is refused`() {
|
||||
val (alice, bob) = pair()
|
||||
val blob = SealedSender.seal(
|
||||
envelope(alice, bob, "hello"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic)
|
||||
val broken = blob.copy(ciphertext = blob.ciphertext.copyOf().also { it[0] = (it[0] + 1).toByte() })
|
||||
|
||||
assertFailsWith<Aead.DecryptionFailed> {
|
||||
SealedSender.open(broken, bob.identityDH.private, bob.deviceId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 Anyone can forge the claimed sender, and this records that on purpose.
|
||||
*
|
||||
* The recipient's IK_dh is published to the world, so a seal claiming to be
|
||||
* from anybody is trivial to make. This test exists so that if someone ever
|
||||
* "fixes" the naming to look authoritative, the reason it is a claim is
|
||||
* still written down and still executing.
|
||||
*/
|
||||
@Test
|
||||
fun `anyone holding the recipient's public key can forge the claimed sender`() {
|
||||
val (alice, bob) = pair()
|
||||
val eve = DeviceIdentity.create()
|
||||
|
||||
// Eve seals her own envelope while claiming to be Alice. Nothing stops
|
||||
// her — the seal is confidentiality, not authentication.
|
||||
val forged = SealedSender.seal(
|
||||
envelope(eve, bob, "not really from alice"),
|
||||
fromUser = 7,
|
||||
fromDevice = alice.deviceId,
|
||||
toDevice = bob.deviceId,
|
||||
recipientIKdh = bob.identityDHPublic,
|
||||
)
|
||||
val opened = SealedSender.open(forged, bob.identityDH.private, bob.deviceId)
|
||||
|
||||
// The seal opens and the lie is right there in the claim.
|
||||
assertEquals(alice.deviceId, opened.claimedFromDevice)
|
||||
// What catches it is the inner envelope: responding to it establishes a
|
||||
// session with EVE's identity key, not Alice's, so the identity pin is
|
||||
// what refuses her — never this layer.
|
||||
val session = E2ESession.respond(bob, opened.envelope)
|
||||
assertEquals("not really from alice", session.decrypt(opened.envelope))
|
||||
// The handshake names EVE's identity key, which is what the recipient's
|
||||
// identity pin compares against — and what refuses her.
|
||||
assertContentEquals(
|
||||
eve.identitySigningPublic,
|
||||
Bytes.unb64(opened.envelope.handshake!!.getString("ik_sig")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a sealed payload is distinguishable from a bare envelope`() {
|
||||
val (alice, bob) = pair()
|
||||
val env = envelope(alice, bob, "hello")
|
||||
val blob = SealedSender.seal(env, 7, alice.deviceId, bob.deviceId, bob.identityDHPublic)
|
||||
|
||||
assertTrue(SealedSender.isSealed(blob.toPayload()))
|
||||
assertFalse(SealedSender.isSealed(env.toPayload()))
|
||||
assertFalse(SealedSender.isSealed("not base64 at all"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every seal uses a fresh ephemeral`() {
|
||||
val (alice, bob) = pair()
|
||||
val one = SealedSender.seal(
|
||||
envelope(alice, bob, "a"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic)
|
||||
val two = SealedSender.seal(
|
||||
envelope(alice, bob, "b"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic)
|
||||
|
||||
// The nonce is derived from the shared secret, so a repeated ephemeral
|
||||
// is a repeated (key, nonce) pair — which under GCM leaks the
|
||||
// authentication subkey and with it the ability to forge.
|
||||
assertFalse(one.eph.contentEquals(two.eph))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the server only ever gets the digest of a delivery key`() {
|
||||
val key = SealedSender.newDeliveryKey()
|
||||
assertEquals(32, key.size)
|
||||
val digest = SealedSender.deliveryKeyDigest(key)
|
||||
assertContentEquals(Bytes.sha256(key), digest)
|
||||
assertFalse(digest.contentEquals(key))
|
||||
|
||||
// And the control envelope hands over the KEY, because that is what a
|
||||
// contact needs and the relay never sees.
|
||||
val json = org.json.JSONObject(SealedSender.deliveryKeyEnvelope(key))
|
||||
assertEquals(Content.Kind.DELIVERY_KEY, json.getString("kind"))
|
||||
assertContentEquals(key, Bytes.unb64(json.getString("key")))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user