Kotlin: the session layer, verified across the wire
An app does not use X3DH and a ratchet, it uses "send this string to that person". This is that layer: device identities with prekeys and rotation, envelopes as the relay actually carries them, and sessions that survive being put away and taken out again. 11 unit tests, and the interop harness grows from 23 assertions to 37 -- the new ones at the envelope level, which is where this protocol's one shipped bug lived. A client and a relay disagreed about a field name and nothing caught it until a live round trip; hand-assembled arguments to X3DH would not have. Kotlin now builds a whole envelope, the web engine's own session layer parses it, and both directions agree on the associated data. Three decisions worth stating. Retired signed prekeys are kept for longer than the relay keeps undelivered mail. A sender fetches a bundle, encrypts against that prekey id, and the envelope can then sit in a queue for the relay's full retention window. Discarding the private half at rotation turns every one of those into an AEAD failure -- which the receiving client cannot distinguish from an attack. A prekey retired past retention is refused outright rather than guessed at, for the same reason: guessing derives a garbage secret and reports it as tampering. The handshake block rides every message until one is answered, not until one is sent. Any message may be the first to arrive. Sessions archive with their skipped message keys, and the harness proves it against the peer rather than against our own bytes. Those keys are the messages already in flight; an archive that drops them loses exactly the mail that was in the air when the app was killed. The failure this whole path exists to prevent is a client that loses session state, re-registers a device, and shows every contact "security keys changed" -- the warning that is supposed to mean an attack. `org.json` is compileOnly: it is in the Android framework, so it costs nothing on a phone and bundling a copy would shadow the platform's. Safe only because the two encodings that must match byte-for-byte -- the ratchet header and the canonical log leaf -- are built by hand; Android's implementation preserves key order and the reference one does not. Also adds PARITY.md: what the iOS app does, what Android does, what is left. It is the definition of done, and a row moves only when something tests it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fc93df24e6
commit
8a3770f1d1
@@ -0,0 +1,211 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import org.json.JSONObject
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class E2ESessionTest {
|
||||
|
||||
/** A bundle for [identity], as its relay would serve one. */
|
||||
private fun bundleFor(identity: DeviceIdentity, withOneTime: Boolean = true): PeerBundle {
|
||||
val oneTime = if (withOneTime) identity.generateOneTimePreKeys(1).first() else null
|
||||
return PeerBundle.fromWire(identity.registrationWire().also { wire ->
|
||||
if (oneTime != null) {
|
||||
wire.put("opk", JSONObject()
|
||||
.put("id", oneTime.id)
|
||||
.put("pub", Bytes.b64(oneTime.publicKey)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a conversation runs both ways`() {
|
||||
val alice = DeviceIdentity.create()
|
||||
val bob = DeviceIdentity.create()
|
||||
|
||||
val a = E2ESession.initiate(alice, bundleFor(bob))
|
||||
val first = a.encrypt("hello")
|
||||
val b = E2ESession.respond(bob, first)
|
||||
assertEquals("hello", b.decrypt(first))
|
||||
|
||||
assertEquals("hi back", a.decrypt(b.encrypt("hi back")))
|
||||
assertEquals("and again", b.decrypt(a.encrypt("and again")))
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ The handshake has to ride EVERY message until one is answered, not
|
||||
* just the first. Any message may be the one that arrives first, and a
|
||||
* conversation where the peer never received the handshake looks, from the
|
||||
* outside, like a contact who has stopped replying.
|
||||
*/
|
||||
@Test
|
||||
fun `the handshake repeats until the peer answers`() {
|
||||
val alice = DeviceIdentity.create()
|
||||
val bob = DeviceIdentity.create()
|
||||
val a = E2ESession.initiate(alice, bundleFor(bob))
|
||||
|
||||
val queued = (1..3).map { a.encrypt("m$it") }
|
||||
assertTrue(queued.all { it.type == "prekey" && it.handshake != null })
|
||||
|
||||
// Bob receives the THIRD one first. It must be enough on its own.
|
||||
val b = E2ESession.respond(bob, queued[2])
|
||||
assertEquals("m3", b.decrypt(queued[2]))
|
||||
assertEquals("m1", b.decrypt(queued[0]))
|
||||
assertEquals("m2", b.decrypt(queued[1]))
|
||||
|
||||
// Once Bob replies and Alice reads it, the block stops.
|
||||
a.decrypt(b.encrypt("got them"))
|
||||
assertEquals("msg", a.encrypt("now plain").type)
|
||||
assertNull(a.encrypt("still plain").handshake)
|
||||
}
|
||||
|
||||
/**
|
||||
* The bug this exists to stop: sessions that lived only in memory. A client
|
||||
* that loses them re-registers a device, and every contact is shown
|
||||
* "security keys changed" — the warning that is supposed to mean an attack.
|
||||
*/
|
||||
@Test
|
||||
fun `a session survives being archived mid-conversation`() {
|
||||
val alice = DeviceIdentity.create()
|
||||
val bob = DeviceIdentity.create()
|
||||
|
||||
var a = E2ESession.initiate(alice, bundleFor(bob))
|
||||
val first = a.encrypt("before the restart")
|
||||
val b = E2ESession.respond(bob, first)
|
||||
assertEquals("before the restart", b.decrypt(first))
|
||||
|
||||
// Both sides go through a restart, at different points in the chain.
|
||||
a = E2ESession.fromArchive(JSONObject(a.archive().toString()))
|
||||
val restoredB = E2ESession.fromArchive(JSONObject(b.archive().toString()))
|
||||
|
||||
assertEquals("after the restart", restoredB.decrypt(a.encrypt("after the restart")))
|
||||
assertEquals("and back", a.decrypt(restoredB.encrypt("and back")))
|
||||
}
|
||||
|
||||
/** Skipped keys are the in-flight messages; losing them at a restart loses those. */
|
||||
@Test
|
||||
fun `messages in flight across a restart still open`() {
|
||||
val alice = DeviceIdentity.create()
|
||||
val bob = DeviceIdentity.create()
|
||||
val a = E2ESession.initiate(alice, bundleFor(bob))
|
||||
val first = a.encrypt("m0")
|
||||
var b = E2ESession.respond(bob, first)
|
||||
b.decrypt(first)
|
||||
|
||||
val later = (1..3).map { a.encrypt("m$it") }
|
||||
b.decrypt(later[2]) // arrives early; 1 and 2 are now skipped keys
|
||||
b = E2ESession.fromArchive(JSONObject(b.archive().toString()))
|
||||
assertEquals("m1", b.decrypt(later[0]))
|
||||
assertEquals("m2", b.decrypt(later[1]))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unsigned prekey is refused`() {
|
||||
val alice = DeviceIdentity.create()
|
||||
val bob = DeviceIdentity.create()
|
||||
val impostor = DeviceIdentity.create()
|
||||
|
||||
// A relay substituting its own prekey, signed by a key it controls.
|
||||
// Nothing else in the protocol catches this.
|
||||
val tampered = bundleFor(bob).copy(
|
||||
signedPreKey = X25519.rawFromPublicKey(impostor.signedPreKey.public),
|
||||
signedPreKeySignature = impostor.signedPreKeySignature(),
|
||||
)
|
||||
assertFailsWith<SecurityException> { E2ESession.initiate(alice, tampered) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a session opens when the one-time prekey pool is drained`() {
|
||||
val alice = DeviceIdentity.create()
|
||||
val bob = DeviceIdentity.create()
|
||||
val a = E2ESession.initiate(alice, bundleFor(bob, withOneTime = false))
|
||||
val first = a.encrypt("no one-time prekey")
|
||||
val b = E2ESession.respond(bob, first)
|
||||
assertEquals("no one-time prekey", b.decrypt(first))
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotation without retention is message loss. A sender that fetched a
|
||||
* bundle before the rotation is still entitled to be understood.
|
||||
*/
|
||||
@Test
|
||||
fun `an envelope built against a retired signed prekey still opens`() {
|
||||
val alice = DeviceIdentity.create()
|
||||
val bob = DeviceIdentity.create()
|
||||
|
||||
val old = bundleFor(bob)
|
||||
val a = E2ESession.initiate(alice, old)
|
||||
val queued = a.encrypt("sent before the rotation")
|
||||
|
||||
bob.rotateSignedPreKey()
|
||||
assertNotEquals(old.signedPreKeyId, bob.signedPreKeyId)
|
||||
|
||||
val b = E2ESession.respond(bob, queued)
|
||||
assertEquals("sent before the rotation", b.decrypt(queued))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a signed prekey retired past retention is refused rather than guessed`() {
|
||||
val alice = DeviceIdentity.create()
|
||||
val bob = DeviceIdentity.create()
|
||||
val queued = E2ESession.initiate(alice, bundleFor(bob)).encrypt("far too late")
|
||||
|
||||
// Rotate, then let the retention window pass. Guessing at the current
|
||||
// key here would derive a garbage secret and report it as tampering.
|
||||
bob.rotateSignedPreKey()
|
||||
bob.pruneSignedPreKeys(System.currentTimeMillis() + DeviceIdentity.RETAIN_AFTER_MS + 1)
|
||||
assertFailsWith<IllegalArgumentException> { E2ESession.respond(bob, queued) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a one-time prekey is consumed exactly once`() {
|
||||
val bob = DeviceIdentity.create()
|
||||
val key = bob.generateOneTimePreKeys(1).first()
|
||||
assertEquals(1, bob.oneTimePreKeyCount)
|
||||
assertTrue(bob.takeOneTimePreKey(key.id) != null)
|
||||
assertNull(bob.takeOneTimePreKey(key.id))
|
||||
assertEquals(0, bob.oneTimePreKeyCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an envelope survives the payload round trip`() {
|
||||
val alice = DeviceIdentity.create()
|
||||
val bob = DeviceIdentity.create()
|
||||
val a = E2ESession.initiate(alice, bundleFor(bob))
|
||||
val sent = a.encrypt("through the relay 👋")
|
||||
|
||||
val received = Envelope.fromPayload(sent.toPayload())
|
||||
assertEquals("prekey", received.type)
|
||||
val b = E2ESession.respond(bob, received)
|
||||
assertEquals("through the relay 👋", b.decrypt(received))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a device identity survives being archived`() {
|
||||
val original = DeviceIdentity.create()
|
||||
original.generateOneTimePreKeys(3)
|
||||
original.rotateSignedPreKey()
|
||||
|
||||
val restored = DeviceIdentity.fromArchive(JSONObject(original.archive().toString()))
|
||||
assertEquals(original.deviceId, restored.deviceId)
|
||||
assertEquals(original.signedPreKeyId, restored.signedPreKeyId)
|
||||
assertEquals(3, restored.oneTimePreKeyCount)
|
||||
assertContentEqualsB64(original.identityDHPublic, restored.identityDHPublic)
|
||||
assertContentEqualsB64(original.identitySigningPublic, restored.identitySigningPublic)
|
||||
|
||||
// The restored device must still be able to complete a handshake, which
|
||||
// is the only thing that proves the PRIVATE halves came back too.
|
||||
val alice = DeviceIdentity.create()
|
||||
val bundle = PeerBundle.fromWire(restored.registrationWire())
|
||||
val session = E2ESession.initiate(alice, bundle)
|
||||
val first = session.encrypt("after a reload")
|
||||
assertEquals("after a reload", E2ESession.respond(restored, first).decrypt(first))
|
||||
}
|
||||
|
||||
private fun assertContentEqualsB64(expected: ByteArray, actual: ByteArray) =
|
||||
assertEquals(Bytes.b64(expected), Bytes.b64(actual))
|
||||
}
|
||||
Reference in New Issue
Block a user