SyncFrameTransportTest.kt

7.3 KB · raw

1package com.peachpasswords.sync23import kotlinx.coroutines.async4import kotlinx.coroutines.channels.Channel5import kotlinx.coroutines.runBlocking6import org.bouncycastle.crypto.params.X25519PrivateKeyParameters7import org.junit.Assert.assertArrayEquals8import org.junit.Assert.assertEquals9import org.junit.Assert.assertFalse10import org.junit.Assert.assertTrue11import org.junit.Test12import java.security.SecureRandom1314/**15 * E7b end-to-end chunking — the deliverable for the wire shape. Initiator and16 * responder each build a real [noiseKKInitiator]/[noiseKKResponder] session,17 * pipe ciphertexts through an in-memory channel, and run [sendSyncFrame] /18 * [receiveSyncFrame] across a payload large enough that it MUST chunk.19 *20 * Pins the protocol that the browser, an Android phone-to-phone pair, and21 * future Android↔iOS ports all depend on: 0x01 marker on every non-last22 * chunk, no marker on the last, reassembled bytes equal original, and a23 * single-chunk frame goes out as one message unchanged.24 *25 * Mirrors the invariants in peach-browser `sync-padding.test.ts` and26 * exercises the wire format end-to-end the browser's transport.test.ts does27 * not.28 */29class SyncFrameTransportTest {30    private data class KeyPair(val publicKey: ByteArray, val privateKey: ByteArray)3132    private fun keys(): KeyPair {33        val privateKey = X25519PrivateKeyParameters(SecureRandom())34        return KeyPair(privateKey.generatePublicKey().encoded, privateKey.encoded)35    }3637    /**38     * Build two sessions (initiator and responder) against each other, sharing39     * an in-memory channel pair so the test owns every ciphertext on the wire40     * — no socket, no real network.41     */42    private fun sessions(): Pair<NoiseNKSession, NoiseNKSession> = runBlocking {43        val initiator = keys()44        val responder = keys()45        val aToB = Channel<ByteArray>(Channel.UNLIMITED)46        val bToA = Channel<ByteArray>(Channel.UNLIMITED)47        val initiatorHandshake = async {48            noiseKKInitiator(49                initiator.privateKey,50                initiator.publicKey,51                responder.publicKey,52                { aToB.send(it) },53                { bToA.receive() },54            )55        }56        val responderHandshake = async {57            noiseKKResponder(58                responder.privateKey,59                responder.publicKey,60                initiator.publicKey,61                { bToA.send(it) },62                { aToB.receive() },63            )64        }65        initiatorHandshake.await() to responderHandshake.await()66    }6768    /**69     * Capture every ciphertext the chunking helper would write onto the wire70     * and the noise session would produce for it. We instrument the session71     * itself by routing [sendCiphertext] and [receiveCiphertext] through an72     * in-memory channel and observing the pre-encryption plaintexts as the73     * test reads them back.74     */75    private class Wire(76        val outbound: Channel<ByteArray>,77        val ciphertextBack: Channel<ByteArray>,78    )7980    private fun bridge(): Pair<Wire, Wire> {81        val aToB = Channel<ByteArray>(Channel.UNLIMITED)82        val bToA = Channel<ByteArray>(Channel.UNLIMITED)83        return Wire(aToB, bToA) to Wire(bToA, aToB)84    }8586    @Test87    fun `a payload just over 64 KiB round-trips across the chunking layer`() = runBlocking {88        val (initiator, responder) = sessions()89        val (aWire, bWire) = bridge()9091        // 70 KiB lands on the bucket ladder at 128 KiB — comfortably above the92        // 65519 byte Noise plaintext limit, forcing at least 3 chunks.93        val payload = ByteArray(70 * 1024) { (it * 17 and 0xFF).toByte() }9495        val sender = async {96            sendSyncFrame(97                payload,98                initiator::encrypt,99            ) { ct -> aWire.outbound.send(ct) }100        }101        val received = async {102            receiveSyncFrame(103                { aWire.ciphertextBack.receive() },104                responder::decrypt,105            )106        }107        sender.await()108        assertArrayEquals(payload, received.await())109        initiator.close()110        responder.close()111    }112113    @Test114    fun `a payload under the Noise limit goes out as exactly one ciphertext`() = runBlocking {115        val (initiator, responder) = sessions()116        val (aWire, bWire) = bridge()117118        // 256-byte payload pads to the 256 minimum bucket — well under 65519.119        val payload = "hello".toByteArray()120121        val sentCiphertexts = mutableListOf<ByteArray>()122        val sender = async {123            sendSyncFrame(124                payload,125                initiator::encrypt,126            ) { ct -> sentCiphertexts.add(ct); aWire.outbound.send(ct) }127        }128        val received = async {129            receiveSyncFrame(130                { aWire.ciphertextBack.receive() },131                responder::decrypt,132            )133        }134        sender.await()135        // Single-chunk path: no marker on the wire, exactly one ciphertext.136        assertEquals(1, sentCiphertexts.size)137        // 256 plaintext + 16 Noise tag = 272 ciphertext.138        assertEquals(256 + 16, sentCiphertexts[0].size)139        assertArrayEquals(payload, received.await())140        initiator.close()141        responder.close()142    }143144    @Test145    fun `a chunked payload marks every non-final chunk with 0x01`() = runBlocking {146        val (initiator, responder) = sessions()147        val (aWire, bWire) = bridge()148149        // 130 KiB payload → padded frame above 192 KiB; definite multi-chunk.150        val payload = ByteArray(130 * 1024) { (it and 0xFF).toByte() }151152        // Wrap encrypt so we can also observe what plaintexts the chunking153        // layer hands to the Noise session. This is what `0x01` is being154        // asserted against on the wire.155        val observedPlaintexts = mutableListOf<ByteArray>()156        val observingEncrypt: (ByteArray) -> ByteArray = { pt ->157            observedPlaintexts.add(pt.copyOf())158            initiator.encrypt(pt)159        }160        val sender = async {161            sendSyncFrame(payload, observingEncrypt) { ct -> aWire.outbound.send(ct) }162        }163        val received = async {164            receiveSyncFrame(165                { aWire.ciphertextBack.receive() },166                responder::decrypt,167            )168        }169        sender.await()170        assertArrayEquals(payload, received.await())171        initiator.close()172        responder.close()173174        // Multi-chunk: at least 2 chunks, every non-final is exactly175        // MAX_NOISE_PLAINTEXT_BYTES and starts with the marker; the final176        // chunk is at most the cap and does not carry the marker.177        assertTrue("expected multi-chunk split", observedPlaintexts.size >= 2)178        observedPlaintexts.dropLast(1).forEach {179            assertEquals(180                "non-final chunk must start with the continuation marker",181                FRAME_CONTINUATION_MARKER, it[0],182            )183            assertEquals(184                "every non-final chunk must hit the Noise cap exactly",185                MAX_NOISE_PLAINTEXT_BYTES, it.size,186            )187        }188        val last = observedPlaintexts.last()189        assertTrue("last chunk must be ≤ the Noise cap", last.size <= MAX_NOISE_PLAINTEXT_BYTES)190        assertFalse(191            "final chunk must NOT carry the continuation marker",192            last.isNotEmpty() && last[0] == FRAME_CONTINUATION_MARKER,193        )194    }195}