-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix: Self-issue Internet PKI certificate for gRPC server (#44)
* 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
Showing
12 changed files
with
360 additions
and
107 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
42 changes: 42 additions & 0 deletions
42
app/src/main/java/tech/relaycorp/cogrpc/server/Networking.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
99 changes: 99 additions & 0 deletions
99
app/src/main/java/tech/relaycorp/cogrpc/server/TLSCertificateGenerator.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
60
app/src/test/java/tech/relaycorp/cogrpc/server/NetworkingTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} | ||
} |
Oops, something went wrong.