SyncFrameTransportTest.kt

6.6 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 */25class SyncFrameTransportTest {26    private data class KeyPair(val publicKey: ByteArray, val privateKey: ByteArray)2728    private fun keys(): KeyPair {29        val privateKey = X25519PrivateKeyParameters(SecureRandom())30        return KeyPair(privateKey.generatePublicKey().encoded, privateKey.encoded)31    }3233    /**34     * Build two Noise sessions against each other. The two halves share an35     * in-memory channel pair so the test owns every ciphertext on the wire —36     * no socket, no real network, and no state from one test leaking into37     * the next.38     */39    private fun sessions(): Pair<NoiseNKSession, NoiseNKSession> = runBlocking {40        val initiator = keys()41        val responder = keys()42        val initiatorToResponder = Channel<ByteArray>(Channel.UNLIMITED)43        val responderToInitiator = Channel<ByteArray>(Channel.UNLIMITED)44        val initiatorHandshake = async {45            noiseKKInitiator(46                initiator.privateKey,47                initiator.publicKey,48                responder.publicKey,49                { initiatorToResponder.send(it) },50                { responderToInitiator.receive() },51            )52        }53        val responderHandshake = async {54            noiseKKResponder(55                responder.privateKey,56                responder.publicKey,57                initiator.publicKey,58                { responderToInitiator.send(it) },59                { initiatorToResponder.receive() },60            )61        }62        initiatorHandshake.await() to responderHandshake.await()63    }6465    @Test66    fun `a payload just over 64 KiB round-trips across the chunking layer`() = runBlocking {67        val (initiator, responder) = sessions()68        val senderToReceiver = Channel<ByteArray>(Channel.UNLIMITED)6970        // 70 KiB lands on the bucket ladder at 128 KiB — comfortably above the71        // 65519 byte Noise plaintext limit, forcing at least 3 chunks.72        val payload = ByteArray(70 * 1024) { (it * 17 and 0xFF).toByte() }7374        val sender = async {75            sendSyncFrame(payload, initiator::encrypt) { ct ->76                senderToReceiver.send(ct)77            }78        }79        val receiver = async {80            receiveSyncFrame({ senderToReceiver.receive() }, responder::decrypt)81        }82        sender.await()83        assertArrayEquals(payload, receiver.await())84        initiator.close()85        responder.close()86    }8788    @Test89    fun `a payload under the Noise limit goes out as exactly one ciphertext`() = runBlocking {90        val (initiator, responder) = sessions()91        val senderToReceiver = Channel<ByteArray>(Channel.UNLIMITED)9293        // 5-byte payload pads to the 256 minimum bucket — well under 65519.94        val payload = "hello".toByteArray()9596        val sentCiphertexts = mutableListOf<ByteArray>()97        val sender = async {98            sendSyncFrame(payload, initiator::encrypt) { ct ->99                sentCiphertexts.add(ct)100                senderToReceiver.send(ct)101            }102        }103        val receiver = async {104            receiveSyncFrame({ senderToReceiver.receive() }, responder::decrypt)105        }106        sender.await()107        // Single-chunk path: no marker on the wire, exactly one ciphertext.108        assertEquals(1, sentCiphertexts.size)109        // 256 plaintext + 16-byte Noise tag = 272 bytes ciphertext.110        assertEquals(256 + 16, sentCiphertexts[0].size)111        assertArrayEquals(payload, receiver.await())112        initiator.close()113        responder.close()114    }115116    @Test117    fun `a chunked payload marks every non-final chunk with 0x01`() = runBlocking {118        val (initiator, responder) = sessions()119        val senderToReceiver = Channel<ByteArray>(Channel.UNLIMITED)120121        // 130 KiB payload → padded frame is well above 192 KiB; definite122        // multi-chunk.123        val payload = ByteArray(130 * 1024) { (it and 0xFF).toByte() }124125        // Wrap encrypt so we can also observe what plaintexts the chunking126        // layer hands to the Noise session. This is what `0x01` is being127        // asserted against on the wire.128        val observedPlaintexts = mutableListOf<ByteArray>()129        val observingEncrypt: (ByteArray) -> ByteArray = { pt ->130            observedPlaintexts.add(pt.copyOf())131            initiator.encrypt(pt)132        }133        val sender = async {134            sendSyncFrame(payload, observingEncrypt) { ct ->135                senderToReceiver.send(ct)136            }137        }138        val receiver = async {139            receiveSyncFrame({ senderToReceiver.receive() }, responder::decrypt)140        }141        sender.await()142        assertArrayEquals(payload, receiver.await())143        initiator.close()144        responder.close()145146        // Multi-chunk: at least 2 chunks, every non-final is at most147        // MAX_NOISE_PLAINTEXT_BYTES (its data leaves one byte for the marker)148        // and starts with the marker; the final chunk is at most the cap and149        // does not carry the marker.150        assertTrue("expected multi-chunk split", observedPlaintexts.size >= 2)151        observedPlaintexts.dropLast(1).forEach {152            assertEquals(153                "non-final chunk must start with the continuation marker",154                FRAME_CONTINUATION_MARKER, it[0],155            )156            assertTrue(157                "every non-final chunk must fit under the Noise cap with the marker",158                it.size <= MAX_NOISE_PLAINTEXT_BYTES,159            )160        }161        val last = observedPlaintexts.last()162        assertTrue("last chunk must be ≤ the Noise cap", last.size <= MAX_NOISE_PLAINTEXT_BYTES)163        assertFalse(164            "final chunk must NOT carry the continuation marker",165            last.isNotEmpty() && last[0] == FRAME_CONTINUATION_MARKER,166        )167    }168}