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:
Paul Hitt
2026-08-23 15:12:14 -04:00
co-authored by Claude Opus 5
parent fc93df24e6
commit 8a3770f1d1
9 changed files with 993 additions and 6 deletions
@@ -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))
}