diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/Environment.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/Environment.kt index 2c3ad63e..e852dd9f 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/Environment.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/Environment.kt @@ -34,5 +34,8 @@ data class HttpConfig( @Persisted @Serializable data class SslConfig( - val isInsecure: Boolean? = null -) + val isInsecure: Boolean? = null, + val trustedCaCertificates: List = emptyList(), +) { + fun hasCustomConfig() = isInsecure == true || trustedCaCertificates.any { it.isEnabled } +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/ImportedFile.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/ImportedFile.kt new file mode 100644 index 00000000..9283f14e --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/ImportedFile.kt @@ -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 diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/AbstractTransportClient.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/AbstractTransportClient.kt index c85ff11e..a7b8d7eb 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/AbstractTransportClient.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/AbstractTransportClient.kt @@ -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 @@ -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 { @@ -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 } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/GrpcTransportClient.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/GrpcTransportClient.kt index cad9c849..ca8fa477 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/GrpcTransportClient.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/GrpcTransportClient.kt @@ -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() diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/WebSocketTransportClient.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/WebSocketTransportClient.kt index 99b761ec..56c2c5fe 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/WebSocketTransportClient.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/WebSocketTransportClient.kt @@ -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) } } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/util/MultipleTrustCertificateManager.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/util/MultipleTrustCertificateManager.kt new file mode 100644 index 00000000..9870b48c --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/util/MultipleTrustCertificateManager.kt @@ -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) : X509ExtendedTrustManager() { + override fun checkClientTrusted(chain: Array?, authType: String?, socket: Socket?) { + checkClientTrusted(chain, authType) + } + + override fun checkClientTrusted(chain: Array?, authType: String?, engine: SSLEngine?) { + checkClientTrusted(chain, authType) + } + + override fun checkClientTrusted(chain: Array?, 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?, authType: String?, socket: Socket?) { + checkServerTrusted(chain, authType) + } + + override fun checkServerTrusted(chain: Array?, authType: String?, engine: SSLEngine?) { + checkServerTrusted(chain, authType) + } + + override fun checkServerTrusted(chain: Array?, authType: String?) { + trustManagers.forEachIndexed { index, it -> + try { + it.checkServerTrusted(chain, authType) + } catch (e: CertificateException) { + if (index >= trustManagers.lastIndex) { + throw e + } + } + } + } + + override fun getAcceptedIssuers(): Array { + return trustManagers.map { it.acceptedIssuers } + .fold(mutableListOf()) { acc, it -> acc += it; acc } + .toTypedArray() + } + +} \ No newline at end of file diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/SubprojectEnvironmentsEditorDialogView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/SubprojectEnvironmentsEditorDialogView.kt index a49aa62b..1d4bb98a 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/SubprojectEnvironmentsEditorDialogView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/SubprojectEnvironmentsEditorDialogView.kt @@ -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 @@ -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( @@ -175,7 +197,7 @@ fun EnvironmentEditorView( EnvironmentEditorTab.SSL -> EnvironmentSslTabContent( environment = environment, onUpdateEnvironment = onUpdateEnvironment, - modifier = modifier, + modifier = modifier.verticalScroll(rememberScrollState()), ) } } @@ -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, + 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) + } + } } } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/viewmodel/FileDialogState.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/viewmodel/FileDialogState.kt index 2d92fe27..c38d374c 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/viewmodel/FileDialogState.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/viewmodel/FileDialogState.kt @@ -8,6 +8,8 @@ import kotlinx.coroutines.flow.MutableStateFlow /** * This class exists as a workaround to the bug that * "Modifier.clickable" is invoked twice in the Windows OS. + * + * Might be related to https://github.com/JetBrains/compose-multiplatform/issues/3892 */ class FileDialogState { val lastCloseTime = MutableStateFlow(null)