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.assertTrue10import org.junit.Test11import java.security.SecureRandom1213class NoiseKKTest {14 private fun keys(seed: Int? = null): NoiseKKKeyPair {15 val privateKey = if (seed == null) {16 X25519PrivateKeyParameters(SecureRandom())17 } else {18 X25519PrivateKeyParameters(ByteArray(32) { seed.toByte() }, 0)19 }20 return NoiseKKKeyPair(privateKey.generatePublicKey().encoded, privateKey.encoded)21 }2223 @Test24 fun `KK mutually authenticates and carries transport frames`() = runBlocking {25 val initiator = keys()26 val responder = keys()27 val aToB = Channel<ByteArray>(Channel.UNLIMITED)28 val bToA = Channel<ByteArray>(Channel.UNLIMITED)29 val initiatorHandshake = async {30 noiseKKInitiator(31 initiator.secretKey,32 initiator.publicKey,33 responder.publicKey,34 { aToB.send(it) },35 { bToA.receive() },36 )37 }38 val responderHandshake = async {39 noiseKKResponder(40 responder.secretKey,41 responder.publicKey,42 initiator.publicKey,43 { bToA.send(it) },44 { aToB.receive() },45 )46 }47 val a = initiatorHandshake.await()48 val b = responderHandshake.await()4950 assertArrayEquals(a.handshakeHash, b.handshakeHash)51 assertEquals("one", String(b.decrypt(a.encrypt("one".encodeToByteArray()))))52 assertEquals("two", String(a.decrypt(b.encrypt("two".encodeToByteArray()))))53 }5455 @Test56 fun `KK handshake hash matches browser cross-platform vector`() = runBlocking {57 val initiator = keys(0x01)58 val responder = keys(0x02)59 val initiatorEphemeral = keys(0x03)60 val responderEphemeral = keys(0x04)61 val aToB = Channel<ByteArray>(Channel.UNLIMITED)62 val bToA = Channel<ByteArray>(Channel.UNLIMITED)6364 val initiatorHandshake = async {65 noiseKKInitiator(66 initiator.secretKey,67 initiator.publicKey,68 responder.publicKey,69 { aToB.send(it) },70 { bToA.receive() },71 initiatorEphemeral,72 )73 }74 val responderHandshake = async {75 noiseKKResponder(76 responder.secretKey,77 responder.publicKey,78 initiator.publicKey,79 { bToA.send(it) },80 { aToB.receive() },81 responderEphemeral,82 )83 }8485 val a = initiatorHandshake.await()86 val b = responderHandshake.await()87 assertEquals(88 "dced1b411d7dc25716d8fc7fdc6b49418ae5648f323df5fb20a105f3c0acabab",89 pubkeyToHex(a.handshakeHash),90 )91 assertArrayEquals(a.handshakeHash, b.handshakeHash)92 }9394 @Test95 fun `KK rejects an initiator key mismatch without fallback`() = runBlocking {96 val initiator = keys()97 val attacker = keys()98 val responder = keys()99 val aToB = Channel<ByteArray>(Channel.UNLIMITED)100 val bToA = Channel<ByteArray>(Channel.UNLIMITED)101102 val initiatorHandshake = async {103 runCatching {104 noiseKKInitiator(105 initiator.secretKey,106 initiator.publicKey,107 responder.publicKey,108 { aToB.send(it) },109 { bToA.receive() },110 )111 }112 }113 val responderHandshake = async {114 runCatching {115 noiseKKResponder(116 responder.secretKey,117 responder.publicKey,118 attacker.publicKey,119 { bToA.send(it) },120 { aToB.receive() },121 )122 }123 }124125 val failure = responderHandshake.await().exceptionOrNull()126 assertTrue(failure?.message?.contains("decrypt", ignoreCase = true) == true)127 initiatorHandshake.cancel()128 }129130 131 132 133 134 135 private val smallOrderRemoteStatics = listOf(136 "0000000000000000000000000000000000000000000000000000000000000000",137 "0100000000000000000000000000000000000000000000000000000000000000",138 "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b880",139 "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157",140 "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f",141 "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f",142 "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f",143 "cdeb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b880",144 "4c9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f11d7",145 )146147 @Test148 fun `KK initiator rejects every small-order remote static at the DH`() {149 val local = keys(0x0b)150 for (entry in smallOrderRemoteStatics) {151 val session = RustNoiseSession(152 RustNoiseNative.nativeCreateKkInitiator(153 local.secretKey,154 local.publicKey,155 entry.hexToBytes(),156 ),157 )158 try {159 val failure = runCatching { session.writeHandshake() }.exceptionOrNull()160 assertTrue(161 "small-order remote static $entry must fail the handshake at the DH",162 failure != null,163 )164 } finally {165 session.close()166 }167 }168 }169170 @Test171 fun `KK accepts an honest remote static`() {172 val local = keys(0x0b)173 val responder = keys(0x0c)174 val session = RustNoiseSession(175 RustNoiseNative.nativeCreateKkInitiator(176 local.secretKey,177 local.publicKey,178 responder.publicKey,179 ),180 )181 try {182 session.writeHandshake()183 } finally {184 session.close()185 }186 }187}188189private fun String.hexToBytes(): ByteArray =190 ByteArray(length / 2) { index ->191 ((Character.digit(this[index * 2], 16) shl 4) or192 Character.digit(this[index * 2 + 1], 16)).toByte()193 }