NoiseKKTest.kt

6.8 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.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    // PB-26-014 Android parity: the nine-entry small-order X25519 blocklist131    // lives in the shared Rust core, so this test only proves the JNI driver132    // surfaces its rejection. The last two entries produce non-zero DH output,133    // so only the core's small-order guard (not the all-zero-output check)134    // rejects them.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    }