ProtocolTest.kt
| 1 | package de.upapp |
| 2 | |
| 3 | import org.bouncycastle.asn1.x500.X500Name |
| 4 | import org.bouncycastle.asn1.x509.BasicConstraints |
| 5 | import org.bouncycastle.asn1.x509.Extension |
| 6 | import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter |
| 7 | import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder |
| 8 | import org.bouncycastle.crypto.util.PrivateKeyFactory |
| 9 | import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder |
| 10 | import org.bouncycastle.tls.Certificate |
| 11 | import org.bouncycastle.tls.CipherSuite |
| 12 | import org.bouncycastle.tls.DefaultTlsServer |
| 13 | import org.bouncycastle.tls.HashAlgorithm |
| 14 | import org.bouncycastle.tls.SignatureAlgorithm |
| 15 | import org.bouncycastle.tls.SignatureAndHashAlgorithm |
| 16 | import org.bouncycastle.tls.TlsContext |
| 17 | import org.bouncycastle.tls.crypto.TlsCryptoParameters |
| 18 | import org.bouncycastle.tls.crypto.impl.bc.BcDefaultTlsCredentialedSigner |
| 19 | import org.bouncycastle.tls.PSKTlsServer |
| 20 | import org.bouncycastle.tls.TlsPSKIdentityManager |
| 21 | import org.bouncycastle.tls.TlsServerProtocol |
| 22 | import org.bouncycastle.tls.crypto.impl.bc.BcTlsCrypto |
| 23 | import org.junit.Assert.assertArrayEquals |
| 24 | import org.junit.Assert.assertEquals |
| 25 | import org.junit.Assert.assertNull |
| 26 | import org.junit.Assert.assertThrows |
| 27 | import org.junit.Test |
| 28 | import java.io.IOException |
| 29 | import java.io.InputStream |
| 30 | import java.io.OutputStream |
| 31 | import java.math.BigInteger |
| 32 | import java.security.KeyPairGenerator |
| 33 | import java.security.SecureRandom |
| 34 | import java.security.cert.X509Certificate |
| 35 | import java.util.Date |
| 36 | |
| 37 | class ProtocolTest { |
| 38 | @Test |
| 39 | fun decodesSegments() { |
| 40 | assertNull(decode(frame(7, TRANSMIT, u16(3) + byteArrayOf(1, 2)))) |
| 41 | val (s, size) = decode(frame(0xC0E1, TRANSMIT, u16(2) + byteArrayOf(1, 2)) + frame(0, HEARTBEAT))!! |
| 42 | assertEquals(0xC0E1, s.port) |
| 43 | assertArrayEquals(byteArrayOf(1, 2), s.data) |
| 44 | assertEquals(7, size) |
| 45 | // Example from the MIBBridge protocol notes: OPEN from port 0xC0E1 to port 80. |
| 46 | assertArrayEquals(byteArrayOf(0xE1.toByte(), 0xC0.toByte(), 1, 0x50, 0), frame(0xC0E1, OPEN, u16(80))) |
| 47 | } |
| 48 | |
| 49 | @Test |
| 50 | fun plainHttpWithChunkedBody() { |
| 51 | val tunnel = fakeCar { _, input, output -> |
| 52 | input.readHead() |
| 53 | output.write("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nWVW\r\n2\r\nZZ\r\n0\r\n\r\n".toByteArray()) |
| 54 | } |
| 55 | val r = tunnel.open(80).use { exchange(it.input, it.output, "GET", "h", "/car/info/vin") } |
| 56 | assertEquals(200, r.status) |
| 57 | assertEquals("WVWZZ", r.text) |
| 58 | } |
| 59 | |
| 60 | @Test |
| 61 | fun pskSessionMatchesCarCipher() { |
| 62 | val tunnel = fakeCar { _, input, output -> |
| 63 | val tls = TlsServerProtocol(input, output) |
| 64 | tls.accept(object : PSKTlsServer(BcTlsCrypto(SecureRandom()), object : TlsPSKIdentityManager { |
| 65 | override fun getHint(): ByteArray? = null |
| 66 | override fun getPSK(identity: ByteArray) = if (identity.decodeToString() == "user") "secret".toByteArray() else null |
| 67 | }) { |
| 68 | override fun getSupportedCipherSuites() = intArrayOf(CipherSuite.TLS_PSK_WITH_AES_128_CBC_SHA256) |
| 69 | }) |
| 70 | val head = tls.inputStream.readHead()!! |
| 71 | val body = """{"data": [], "request": "${head.lines().first()}"}""" |
| 72 | tls.outputStream.write("HTTP/1.1 200 OK\r\nContent-Length: ${body.length}\r\n\r\n$body".toByteArray()) |
| 73 | tls.close() |
| 74 | } |
| 75 | val r = tunnel.open(443).use { ch -> |
| 76 | val tls = sessionTls(ch.input, ch.output, "user", "secret") |
| 77 | exchange(tls.inputStream, tls.outputStream, "GET", "h", "/car/batteries").also { runCatching { tls.close() } } |
| 78 | } |
| 79 | assertEquals("""{"data": [], "request": "GET /car/batteries HTTP/1.1"}""", r.text) |
| 80 | } |
| 81 | |
| 82 | @Test |
| 83 | fun pinMatchesMapsAndMore() { |
| 84 | // Computed with the maps+more algorithm in Python. |
| 85 | assertEquals("304163", pin(ByteArray(16) { it.toByte() }, ByteArray(16) { (it + 16).toByte() })) |
| 86 | } |
| 87 | |
| 88 | @Test |
| 89 | fun registrationChecksCertificateAndAgreesOnPin() { |
| 90 | val ca = TestCa() |
| 91 | var serverPin = "" |
| 92 | val tunnel = fakeCar { _, input, output -> |
| 93 | val tls = TlsServerProtocol(input, output) |
| 94 | tls.accept(object : DefaultTlsServer(BcTlsCrypto(SecureRandom())) { |
| 95 | override fun getSupportedCipherSuites() = intArrayOf(CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256) |
| 96 | override fun getRSASignerCredentials() = ca.signer(context, crypto as BcTlsCrypto) |
| 97 | override fun notifyHandshakeComplete() { |
| 98 | super.notifyHandshakeComplete() |
| 99 | serverPin = registrationPin(context) |
| 100 | } |
| 101 | }) |
| 102 | tls.inputStream.readHead() |
| 103 | tls.outputStream.write("HTTP/1.1 200 OK\r\nContent-Length: 9\r\n\r\nuser,pass".toByteArray()) |
| 104 | tls.close() |
| 105 | } |
| 106 | val r = tunnel.open(443).use { ch -> |
| 107 | val (tls, pin) = registrationTls(ch.input, ch.output, ca.root) |
| 108 | assertEquals(serverPin, pin) |
| 109 | assertEquals(6, pin.length) |
| 110 | exchange(tls.inputStream, tls.outputStream, "GET", "h", "/auth/x").also { runCatching { tls.close() } } |
| 111 | } |
| 112 | assertEquals("user,pass", r.text) |
| 113 | |
| 114 | val stranger = TestCa() |
| 115 | val ch = tunnel.open(443) |
| 116 | assertThrows(IOException::class.java) { registrationTls(ch.input, ch.output, stranger.root) } |
| 117 | } |
| 118 | |
| 119 | /** A root CA and a server certificate signed by it. The server certificate expired an hour ago. */ |
| 120 | private class TestCa { |
| 121 | private val gen = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) } |
| 122 | private val rootKey = gen.generateKeyPair() |
| 123 | private val leafKey = gen.generateKeyPair() |
| 124 | private val now = System.currentTimeMillis() |
| 125 | val root = cert("CN=Test CA", rootKey.public, true, now - 86_400_000, now + 86_400_000) |
| 126 | private val leaf = cert("CN=Car", leafKey.public, false, now - 7_200_000, now - 3_600_000) |
| 127 | |
| 128 | private fun cert(subject: String, key: java.security.PublicKey, ca: Boolean, from: Long, to: Long): X509Certificate { |
| 129 | val builder = JcaX509v3CertificateBuilder(X500Name("CN=Test CA"), BigInteger.valueOf(from), Date(from), Date(to), X500Name(subject), key) |
| 130 | .addExtension(Extension.basicConstraints, true, BasicConstraints(ca)) |
| 131 | return JcaX509CertificateConverter().getCertificate(builder.build(JcaContentSignerBuilder("SHA256withRSA").build(rootKey.private))) |
| 132 | } |
| 133 | |
| 134 | fun signer(context: TlsContext, crypto: BcTlsCrypto) = BcDefaultTlsCredentialedSigner( |
| 135 | TlsCryptoParameters(context), crypto, PrivateKeyFactory.createKey(leafKey.private.encoded), |
| 136 | Certificate(arrayOf(crypto.createCertificate(leaf.encoded), crypto.createCertificate(root.encoded))), |
| 137 | SignatureAndHashAlgorithm(HashAlgorithm.sha256, SignatureAlgorithm.rsa), |
| 138 | ) |
| 139 | } |
| 140 | |
| 141 | @Test |
| 142 | fun reusesSessionAndRetriesAfterCarCloses() { |
| 143 | var connections = 0 |
| 144 | val tunnel = fakeCar { port, input, output -> |
| 145 | if (port == 80) { |
| 146 | input.readHead() |
| 147 | output.write("HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nVIN".toByteArray()) |
| 148 | return@fakeCar |
| 149 | } |
| 150 | val n = ++connections |
| 151 | val tls = TlsServerProtocol(input, output) |
| 152 | tls.accept(object : PSKTlsServer(BcTlsCrypto(SecureRandom()), object : TlsPSKIdentityManager { |
| 153 | override fun getHint(): ByteArray? = null |
| 154 | override fun getPSK(identity: ByteArray) = "secret".toByteArray() |
| 155 | }) { |
| 156 | override fun getSupportedCipherSuites() = intArrayOf(CipherSuite.TLS_PSK_WITH_AES_128_CBC_SHA256) |
| 157 | }) |
| 158 | // The car closes after three requests without notice. |
| 159 | repeat(3) { |
| 160 | val path = tls.inputStream.readHead()?.lines()?.first()?.split(' ')?.get(1) ?: return@repeat |
| 161 | val body = "$n $path" |
| 162 | tls.outputStream.write("HTTP/1.1 200 OK\r\nContent-Length: ${body.length}\r\n\r\n$body".toByteArray()) |
| 163 | } |
| 164 | tls.close() |
| 165 | } |
| 166 | val car = Car(tunnel, storedCredentials("user,secret")) |
| 167 | car.connect {} |
| 168 | assertEquals("1 /a", car.get("a")) |
| 169 | assertEquals("1 /b", car.get("b")) |
| 170 | assertEquals("2 /c", car.get("c")) |
| 171 | assertEquals(2, connections) |
| 172 | } |
| 173 | |
| 174 | private fun InputStream.readHead(): String? { |
| 175 | val sb = StringBuilder() |
| 176 | while (!sb.endsWith("\r\n\r\n")) sb.append(read().takeIf { it >= 0 }?.toChar() ?: return null) |
| 177 | return sb.toString() |
| 178 | } |
| 179 | |
| 180 | private fun fakeCar(serve: (port: Int, InputStream, OutputStream) -> Unit) = CarSide(serve).tunnel |
| 181 | } |
| 182 |