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.SecureRandom13141516171819202122232425class 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 343536373839 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 71 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 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 108 assertEquals(1, sentCiphertexts.size)109 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 122 123 val payload = ByteArray(130 * 1024) { (it and 0xFF).toByte() }124125 126 127 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 147 148 149 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}