Skip to content

Commit

Permalink
fix: Self-issue Internet PKI certificate for gRPC server (#44)
Browse files Browse the repository at this point in the history
* Implement PoC

* Complete implementation

* Ignore Bouncy Castle error reported by lint

* Upgrade Bouncy Castle

* Silence irrelevant lint violation

* Address synthetic method warnings

* Add missing tests for DER to PEM conversion

* Replace Apache Commons' Base64 encoder with BouncyCastle's

* Calculate IP address of device in its own WiFi hotspot network

* Move certificate generation to internally swappable field

* Use flatMap and change Array to List

* Use Bouncy Castle in cogrpc lib to use base64 encoder
  • Loading branch information
gnarea authored May 8, 2020
1 parent 09f0a28 commit a8284c9
Show file tree
Hide file tree
Showing 12 changed files with 360 additions and 107 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

[Relaynet](https://relaynet.link) Courier Android app.

## Limitations

- Rooted devices or those using a fork of Android must have exactly one IP address in the subnet `192.168.43.0/24`, or else the courier app will fail to allow incoming connections from private gateways: We need exactly one IP address in that range so that the app can self-issue TLS certificates for it. This shouldn't be a problem with an unmodified OS.

## Development

### Run unit tests
Expand Down
7 changes: 1 addition & 6 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,7 @@ dependencies {
// Android TLS support for Netty
implementation "io.netty:netty-handler:$nettyVersion"
implementation 'org.conscrypt:conscrypt-android:2.4.0'

// Base64 encoding
// Android comes bundled with 1.3 (but hidden), so we're stuck with this version.
// More info at https://blog.osom.info/2015/04/commons-codec-on-android.html
//noinspection GradleDependency
api 'commons-codec:commons-codec:1.3'
implementation("org.bouncycastle:bcpkix-jdk15on:1.65")

// Testing
testImplementation 'org.junit.jupiter:junit-jupiter:5.6.2'
Expand Down
8 changes: 6 additions & 2 deletions app/lint.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<lint>
<issue id="NewerVersionAvailable">
<ignore regexp="commons-codec:commons-codec" />
<issue id="InvalidPackage">
<!-- Ignore errors about BC importing javax.naming because we don't use those modules -->
<ignore path="**/bcpkix-*.jar"/>
</issue>
<issue id="TrustAllX509TrustManager">
<ignore path="org/bouncycastle/est/jcajce/*.class"/>
</issue>
</lint>
15 changes: 9 additions & 6 deletions app/src/main/java/tech/relaycorp/cogrpc/server/CogRPCServer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ internal constructor(
private var server: Server? = null

private val clientsInterceptor by lazy { ClientsConnectedFilter() }
private val certificateInputStream get() = getResource("cert.pem")
private val keyInputStream get() = getResource("key.pem")

internal var tlsCertificateGeneratorProvider: ((ipAddress: String) -> TLSCertificateGenerator) =
TLSCertificateGenerator.Companion::generate

suspend fun start(
service: Service,
Expand All @@ -46,14 +47,19 @@ internal constructor(
withContext(Dispatchers.IO) {
setupTLSProvider()

val certGenerator = tlsCertificateGeneratorProvider(Networking.getGatewayIpAddress())

val server = NettyServerBuilder
.forAddress(InetSocketAddress(hostname, port))
.maxInboundMessageSize(MAX_MESSAGE_SIZE)
.maxInboundMetadataSize(MAX_METADATA_SIZE)
.maxConcurrentCallsPerConnection(MAX_CONCURRENT_CALLS_PER_CONNECTION)
.maxConnectionAge(MAX_CONNECTION_AGE.inSeconds.roundToLong(), TimeUnit.SECONDS)
.maxConnectionIdle(MAX_CONNECTION_IDLE.inSeconds.roundToLong(), TimeUnit.SECONDS)
.useTransportSecurity(certificateInputStream, keyInputStream)
.useTransportSecurity(
certGenerator.exportCertificate().inputStream(),
certGenerator.exportPrivateKey().inputStream()
)
.addService(CogRPCConnectionService(coroutineScope, service))
.intercept(AuthorizationContext.interceptor)
.addTransportFilter(clientsInterceptor)
Expand Down Expand Up @@ -82,9 +88,6 @@ internal constructor(

fun clientsConnected() = clientsInterceptor.clientsCount()

private fun getResource(path: String) =
javaClass.classLoader!!.getResourceAsStream(path)

private fun setupTLSProvider() {
Security.insertProviderAt(Conscrypt.newProvider(), 1)
}
Expand Down
42 changes: 42 additions & 0 deletions app/src/main/java/tech/relaycorp/cogrpc/server/Networking.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package tech.relaycorp.cogrpc.server

import java.net.NetworkInterface

internal object Networking {
private const val androidGatewaySubnetPrefix = "192.168.43."

/**
* Return the local IP address used by the current device in the WiFi hotspot network it created
*
* An exception may be thrown in rooted devices or those with a custom version of Android where
* the number of local IP addresses in the subnet 192.168.43.0/24 does not equal one.
*/
@Throws(GatewayIPAddressException::class)
fun getGatewayIpAddress(): String {
val localAddresses = getAllLocalIpAddresses()
val gatewayAddresses =
localAddresses.filter { it.startsWith(androidGatewaySubnetPrefix) }
if (gatewayAddresses.isEmpty()) {
throw GatewayIPAddressException(
"No address in the subnet ${androidGatewaySubnetPrefix}0/24 was found"
)
} else if (gatewayAddresses.size > 1) {
throw GatewayIPAddressException(
"Multiple addresses in the subnet ${androidGatewaySubnetPrefix}0/24 were found"
)
}
return gatewayAddresses.first()
}

fun getAllLocalIpAddresses(): List<String> {
val networkInterfaces = NetworkInterface.getNetworkInterfaces().iterator().asSequence()
val localAddresses = networkInterfaces.flatMap {
it.inetAddresses.asSequence()
.filter { inetAddress -> inetAddress.isSiteLocalAddress }
.map { inetAddress -> inetAddress.hostAddress }
}
return localAddresses.toList()
}
}

class GatewayIPAddressException(message: String) : Exception(message)
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package tech.relaycorp.cogrpc.server

import org.bouncycastle.asn1.x500.X500Name
import org.bouncycastle.asn1.x500.X500NameBuilder
import org.bouncycastle.asn1.x500.style.BCStyle
import org.bouncycastle.asn1.x509.Extension
import org.bouncycastle.asn1.x509.GeneralName
import org.bouncycastle.asn1.x509.GeneralNamesBuilder
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo
import org.bouncycastle.cert.X509CertificateHolder
import org.bouncycastle.cert.X509v3CertificateBuilder
import org.bouncycastle.crypto.params.AsymmetricKeyParameter
import org.bouncycastle.crypto.util.PrivateKeyFactory
import org.bouncycastle.operator.ContentSigner
import org.bouncycastle.operator.DefaultDigestAlgorithmIdentifierFinder
import org.bouncycastle.operator.DefaultSignatureAlgorithmIdentifierFinder
import org.bouncycastle.operator.bc.BcRSAContentSignerBuilder
import org.bouncycastle.util.encoders.Base64
import java.math.BigInteger
import java.security.KeyPairGenerator
import java.security.PrivateKey
import java.security.SecureRandom
import java.security.cert.CertificateException
import java.time.ZoneOffset.UTC
import java.time.ZonedDateTime
import java.util.Date

class TLSCertificateGenerator(
val privateKey: PrivateKey,
val certificateHolder: X509CertificateHolder
) {
fun exportPrivateKey() = derToPem(this.privateKey.encoded, "PRIVATE KEY")

fun exportCertificate() = derToPem(this.certificateHolder.encoded, "CERTIFICATE")

private fun derToPem(valueDer: ByteArray, pemLabel: String): ByteArray {
val valueBase64 = Base64.toBase64String(valueDer)
val valuePem = "-----BEGIN $pemLabel-----\n${valueBase64}\n-----END $pemLabel-----"
return valuePem.toByteArray()
}

companion object {
private const val RSA_KEY_MODULUS = 2048

fun generate(ipAddress: String): TLSCertificateGenerator {
val keyGen = KeyPairGenerator.getInstance("RSA")
keyGen.initialize(this.RSA_KEY_MODULUS)
val keyPair = keyGen.generateKeyPair()

val distinguishedName = buildDistinguishedName(ipAddress)

val now = ZonedDateTime.now(UTC)
val builder = X509v3CertificateBuilder(
distinguishedName,
generateRandomBigInteger(),
Date.from(now.minusHours(2).toInstant()), // Account for clock drift
Date.from(now.plusHours(24).toInstant()),
distinguishedName,
SubjectPublicKeyInfo.getInstance(keyPair.public.encoded)
)

val sanExtensionBuilder = GeneralNamesBuilder()
sanExtensionBuilder.addName(GeneralName(GeneralName.iPAddress, ipAddress))
builder.addExtension(
Extension.subjectAlternativeName,
true,
sanExtensionBuilder.build()
)

val signerBuilder = makeSigner(keyPair.private)
val certificateHolder = builder.build(signerBuilder)

return TLSCertificateGenerator(keyPair.private, certificateHolder)
}

@Throws(CertificateException::class)
private fun buildDistinguishedName(ipAddress: String): X500Name {
val builder = X500NameBuilder(BCStyle.INSTANCE)
builder.addRDN(BCStyle.CN, ipAddress)
return builder.build()
}

private fun makeSigner(issuerPrivateKey: PrivateKey): ContentSigner {
val signatureAlgorithm =
DefaultSignatureAlgorithmIdentifierFinder().find("SHA256WithRSAEncryption")
val digestAlgorithm = DefaultDigestAlgorithmIdentifierFinder().find(signatureAlgorithm)
val privateKeyParam: AsymmetricKeyParameter =
PrivateKeyFactory.createKey(issuerPrivateKey.encoded)
val contentSignerBuilder =
BcRSAContentSignerBuilder(signatureAlgorithm, digestAlgorithm)
return contentSignerBuilder.build(privateKeyParam)
}

private fun generateRandomBigInteger(): BigInteger {
val random = SecureRandom()
return BigInteger(64, random)
}
}
}
60 changes: 60 additions & 0 deletions app/src/test/java/tech/relaycorp/cogrpc/server/NetworkingTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package tech.relaycorp.cogrpc.server

import com.nhaarman.mockitokotlin2.spy
import com.nhaarman.mockitokotlin2.whenever
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows

class NetworkingTest {
@Nested
inner class GetGatewayIpAddress {
private val spiedNetworking = spy(Networking)
private val localIPAddress = "192.168.43.1"

@Test
fun `An exception should be thrown if no IP address could be found`() {
whenever(spiedNetworking.getAllLocalIpAddresses()).thenReturn(emptyList())

val exception =
assertThrows<GatewayIPAddressException> { spiedNetworking.getGatewayIpAddress() }

assertEquals(
"No address in the subnet 192.168.43.0/24 was found",
exception.message
)
}

@Test
fun `An exception should be thrown if multiple IP addresses are found`() {
whenever(spiedNetworking.getAllLocalIpAddresses()).thenReturn(
listOf(localIPAddress, "192.168.43.200")
)

val exception =
assertThrows<GatewayIPAddressException> { spiedNetworking.getGatewayIpAddress() }

assertEquals(
"Multiple addresses in the subnet 192.168.43.0/24 were found",
exception.message
)
}

@Test
fun `Addresses that do not start with the Android gateway prefix should be ignored`() {
whenever(spiedNetworking.getAllLocalIpAddresses()).thenReturn(
listOf("10.0.0.1", localIPAddress)
)

assertEquals(spiedNetworking.getGatewayIpAddress(), localIPAddress)
}

@Test
fun `The IP address should be returned if there is only one address`() {
whenever(spiedNetworking.getAllLocalIpAddresses()).thenReturn(listOf(localIPAddress))

assertEquals(spiedNetworking.getGatewayIpAddress(), localIPAddress)
}
}
}
Loading

0 comments on commit a8284c9

Please sign in to comment.