Skip to content

Commit

Permalink
add customizable accepted CA certificates to SSL config
Browse files Browse the repository at this point in the history
  • Loading branch information
sunny-chung committed Dec 10, 2023
1 parent 90ed4e6 commit f0e8a46
Show file tree
Hide file tree
Showing 8 changed files with 284 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,8 @@ data class HttpConfig(
@Persisted
@Serializable
data class SslConfig(
val isInsecure: Boolean? = null
)
val isInsecure: Boolean? = null,
val trustedCaCertificates: List<ImportedFile> = emptyList(),
) {
fun hasCustomConfig() = isInsecure == true || trustedCaCertificates.any { it.isEnabled }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.sunnychung.application.multiplatform.hellohttp.model

import com.sunnychung.application.multiplatform.hellohttp.annotation.Persisted
import com.sunnychung.application.multiplatform.hellohttp.document.Identifiable
import com.sunnychung.lib.multiplatform.kdatetime.KInstant
import kotlinx.serialization.Serializable

@Persisted
@Serializable
data class ImportedFile(
override val id: String,
val name: String,
val originalFilename: String,
val createdWhen: KInstant,
val isEnabled: Boolean,
val content: ByteArray,
) : Identifiable
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.sunnychung.application.multiplatform.hellohttp.manager.CallDataStore
import com.sunnychung.application.multiplatform.hellohttp.model.RawExchange
import com.sunnychung.application.multiplatform.hellohttp.model.SslConfig
import com.sunnychung.application.multiplatform.hellohttp.model.UserResponse
import com.sunnychung.application.multiplatform.hellohttp.network.util.MultipleTrustCertificateManager
import com.sunnychung.application.multiplatform.hellohttp.network.util.TrustAllSslCertificateManager
import com.sunnychung.application.multiplatform.hellohttp.util.log
import com.sunnychung.application.multiplatform.hellohttp.util.uuidString
Expand All @@ -24,10 +25,13 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.yield
import java.io.ByteArrayOutputStream
import java.security.KeyStore
import java.security.SecureRandom
import java.security.cert.CertificateFactory
import java.util.concurrent.atomic.AtomicInteger
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager

abstract class AbstractTransportClient internal constructor(callDataStore: CallDataStore) : TransportClient {
Expand Down Expand Up @@ -67,12 +71,39 @@ abstract class AbstractTransportClient internal constructor(callDataStore: CallD
init(null, arrayOf(trustManager), SecureRandom())
Pair(this, trustManager)
} else {
init(null, null, SecureRandom())
Pair(this, null)
val customCaCertificates = sslConfig.trustedCaCertificates.filter { it.isEnabled }
if (customCaCertificates.isNotEmpty()) {
val defaultX509TrustManager = createTrustManager(null)
val trustStore = KeyStore.getInstance(KeyStore.getDefaultType())
trustStore.load(null)
customCaCertificates.map {
val cert = CertificateFactory.getInstance("X.509").generateCertificate(it.content.inputStream())
trustStore.setCertificateEntry(it.name, cert)
}
val customTrustManager = createTrustManager(trustStore)
val combinedTrustManager = MultipleTrustCertificateManager(
listOf(defaultX509TrustManager, customTrustManager)
)
init(null, arrayOf(combinedTrustManager), SecureRandom())
Pair(this, combinedTrustManager)
} else {
init(null, null, SecureRandom())
Pair(this, null)
}
}
}
}

private fun createTrustManager(keystore: KeyStore?): X509TrustManager {
return TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
.apply {
init(keystore)
}
.trustManagers
.filterIsInstance(X509TrustManager::class.java)
.first()
}

protected fun createHostnameVerifier(sslConfig: SslConfig): HostnameVerifier? {
if (sslConfig.isInsecure == true) {
return HostnameVerifier { _, _ -> true }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,7 @@ class GrpcTransportClient(networkClientManager: NetworkClientManager) : Abstract
if (uri0.scheme in setOf("http", "grpc")) {
usePlaintext();
} else {
if (sslConfig.isInsecure == true) {
log.d { "Insecure gRPC" }
if (sslConfig.hasCustomConfig()) {
GrpcSslContexts.forClient()
.trustManager(createSslContext(sslConfig).second)
.build()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ open class WebSocketTransportClient(networkClientManager: NetworkClientManager)
fun configureWebSocketClient(client: WebSocketClient, callId: String, sslConfig: SslConfig) {
with (client) {
setDnsResolver(createDnsResolver(callId))
if (uri.scheme == "wss" && sslConfig.isInsecure == true) {
if (uri.scheme == "wss" && sslConfig.hasCustomConfig()) {
setSocketFactory(createSslContext(sslConfig).first.socketFactory)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.sunnychung.application.multiplatform.hellohttp.network.util

import java.net.Socket
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
import javax.net.ssl.SSLEngine
import javax.net.ssl.X509ExtendedTrustManager
import javax.net.ssl.X509TrustManager

class MultipleTrustCertificateManager(private val trustManagers: List<X509TrustManager>) : X509ExtendedTrustManager() {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?, socket: Socket?) {
checkClientTrusted(chain, authType)
}

override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?, engine: SSLEngine?) {
checkClientTrusted(chain, authType)
}

override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
trustManagers.forEachIndexed { index, it ->
try {
it.checkClientTrusted(chain, authType)
} catch (e: CertificateException) {
if (index >= trustManagers.lastIndex) {
throw e
}
}
}
}

override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?, socket: Socket?) {
checkServerTrusted(chain, authType)
}

override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?, engine: SSLEngine?) {
checkServerTrusted(chain, authType)
}

override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
trustManagers.forEachIndexed { index, it ->
try {
it.checkServerTrusted(chain, authType)
} catch (e: CertificateException) {
if (index >= trustManagers.lastIndex) {
throw e
}
}
}
}

override fun getAcceptedIssuers(): Array<X509Certificate> {
return trustManagers.map { it.acceptedIssuers }
.fold(mutableListOf<X509Certificate>()) { acc, it -> acc += it; acc }
.toTypedArray()
}

}
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
package com.sunnychung.application.multiplatform.hellohttp.ux

import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
Expand All @@ -23,20 +29,36 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.sunnychung.application.multiplatform.hellohttp.model.Environment
import com.sunnychung.application.multiplatform.hellohttp.model.HttpConfig
import com.sunnychung.application.multiplatform.hellohttp.model.ImportedFile
import com.sunnychung.application.multiplatform.hellohttp.model.Subproject
import com.sunnychung.application.multiplatform.hellohttp.model.UserKeyValuePair
import com.sunnychung.application.multiplatform.hellohttp.util.copyWithChange
import com.sunnychung.application.multiplatform.hellohttp.util.copyWithIndexedChange
import com.sunnychung.application.multiplatform.hellohttp.util.copyWithRemoval
import com.sunnychung.application.multiplatform.hellohttp.util.copyWithRemovedIndex
import com.sunnychung.application.multiplatform.hellohttp.util.log
import com.sunnychung.application.multiplatform.hellohttp.util.uuidString
import com.sunnychung.application.multiplatform.hellohttp.ux.local.LocalColor
import com.sunnychung.application.multiplatform.hellohttp.ux.viewmodel.rememberFileDialogState
import com.sunnychung.lib.multiplatform.kdatetime.KDateTimeFormat
import com.sunnychung.lib.multiplatform.kdatetime.KInstant
import com.sunnychung.lib.multiplatform.kdatetime.KZoneOffset
import com.sunnychung.lib.multiplatform.kdatetime.KZonedDateTime
import com.sunnychung.lib.multiplatform.kdatetime.KZonedInstant
import java.io.File
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import javax.security.auth.x500.X500Principal

@Composable
fun SubprojectEnvironmentsEditorDialogView(
Expand Down Expand Up @@ -175,7 +197,7 @@ fun EnvironmentEditorView(
EnvironmentEditorTab.SSL -> EnvironmentSslTabContent(
environment = environment,
onUpdateEnvironment = onUpdateEnvironment,
modifier = modifier,
modifier = modifier.verticalScroll(rememberScrollState()),
)
}
}
Expand Down Expand Up @@ -289,6 +311,151 @@ fun EnvironmentSslTabContent(
modifier = Modifier.align(Alignment.CenterEnd)
)
}
CertificateEditorView(
title = "Additional Trusted CA Certificates",
certificates = sslConfig.trustedCaCertificates,
onAddCertificate = { new ->
onUpdateEnvironment(
environment.copy(sslConfig = sslConfig.copy(
trustedCaCertificates = sslConfig.trustedCaCertificates + new
))
)
},
onUpdateCertificate = { update ->
onUpdateEnvironment(
environment.copy(sslConfig = sslConfig.copy(
trustedCaCertificates = sslConfig.trustedCaCertificates.copyWithChange(update)
))
)
},
onDeleteCertificate = { delete ->
onUpdateEnvironment(
environment.copy(
sslConfig = sslConfig.copy(
trustedCaCertificates = sslConfig.trustedCaCertificates.copyWithRemoval { it.id == delete.id }
)
)
)
},
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
}

@Composable
fun CertificateEditorView(
modifier: Modifier,
title: String,
certificates: List<ImportedFile>,
onAddCertificate: (ImportedFile) -> Unit,
onUpdateCertificate: (ImportedFile) -> Unit,
onDeleteCertificate: (ImportedFile) -> Unit,
) {
val colours = LocalColor.current

var isShowFileDialog by remember { mutableStateOf(false) }
val fileDialogState = rememberFileDialogState()

fun parseAndAddCertificate(path: String) {
val file = File(path)
val content = file.readBytes()
val cert: X509Certificate = CertificateFactory.getInstance("X.509").generateCertificate(content.inputStream()) as X509Certificate
log.d { "Loaded cert ${cert}" }

onAddCertificate(
ImportedFile(
id = uuidString(),
name = cert.subjectX500Principal.getName(X500Principal.RFC1779) +
"\nExpiry: ${KZonedInstant(cert.notAfter.time, KZoneOffset.local()).format(KDateTimeFormat.ISO8601_DATETIME.pattern)}" +
if (cert.keyUsage?.get(5) != true) "\n⚠️ Not a CA certificate!" else ""
,
originalFilename = file.name,
createdWhen = KInstant.now(),
isEnabled = true,
content = content,
)
)
}

Column(modifier) {
Box(modifier = Modifier.fillMaxWidth()) {
AppText(text = title, modifier = Modifier.align(Alignment.CenterStart).padding(vertical = 6.dp))
AppImageButton(
resource = "add.svg",
size = 24.dp,
onClick = { isShowFileDialog = true },
modifier = Modifier.align(Alignment.CenterEnd).padding(end = 4.dp)
)
}
Column(modifier = Modifier.fillMaxWidth().padding(start = 8.dp)) {
Row(modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Max)) {
AppText(
text = "Certificate",
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(0.7f)
.fillMaxHeight()
.border(width = 1.dp, color = colours.placeholder, RectangleShape)
.padding(all = 8.dp)
)
AppText(
text = "Import Time",
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(0.3f)
.fillMaxHeight()
.border(width = 1.dp, color = colours.placeholder, RectangleShape)
.padding(all = 8.dp)
)
Spacer(modifier = Modifier.width(24.dp + 24.dp))
}
certificates.forEach {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Max)) {
AppText(
text = it.name,
modifier = Modifier.weight(0.7f)
.fillMaxHeight()
.border(width = 1.dp, color = colours.placeholder, RectangleShape)
.padding(all = 8.dp)
)
AppText(
text = it.createdWhen.atZoneOffset(KZoneOffset.local()).format("yyyy-MM-dd HH:mm"),
modifier = Modifier.weight(0.3f)
.fillMaxHeight()
.border(width = 1.dp, color = colours.placeholder, RectangleShape)
.padding(all = 8.dp)
)
AppCheckbox(
checked = it.isEnabled,
onCheckedChange = { v -> onUpdateCertificate(it.copy(isEnabled = v)) },
size = 16.dp,
modifier = Modifier.padding(horizontal = 4.dp)
)
AppDeleteButton(
onClickDelete = { onDeleteCertificate(it) },
modifier = Modifier.padding(horizontal = 4.dp)
)
}
}
if (certificates.isEmpty()) {
Row {
AppText(
text = "No entry",
modifier = Modifier.weight(1f)
.border(width = 1.dp, color = colours.placeholder, RectangleShape)
.padding(all = 8.dp)
)
Spacer(modifier = Modifier.width(24.dp + 24.dp))
}
}
}
}

if (isShowFileDialog) {
FileDialog(state = fileDialogState) {
isShowFileDialog = false
if (it != null && it.isNotEmpty()) {
parseAndAddCertificate(it.first().absolutePath)
}
}
}
}

Expand Down
Loading

0 comments on commit f0e8a46

Please sign in to comment.