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.SecureRandom1314151617181920212223242526272829class 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 3839404142 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 69707172737475 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 92 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 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 136 assertEquals(1, sentCiphertexts.size)137 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 150 val payload = ByteArray(130 * 1024) { (it and 0xFF).toByte() }151152 153 154 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 175 176 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}