Skip to content

Commit

Permalink
add importing PKCS#12 (known as p12) and PFX bundle files as client c…
Browse files Browse the repository at this point in the history
…ertificates
  • Loading branch information
sunny-chung committed Nov 15, 2024
1 parent 6771ea1 commit ca7bbef
Show file tree
Hide file tree
Showing 8 changed files with 180 additions and 35 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- Number badges in Request Parameter Type tabs to indicate the number of active entries declared in the selected example, e.g. the number of active key-value pairs of a multipart request body declared in the selected Request Example.
- Certificates in P7B (PKCS#7) format can now be imported
- Private keys in PEM or PKCS#1 formats can now be imported, and does not limit to RSA keys anymore.
- PKCS#12 (known as p12) and PFX files can now be imported as client certificates

### Changed
- Importing CA certificates now imports all the certificates from an input file
Expand Down
2 changes: 2 additions & 0 deletions doc/features/ssl-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ Accepted formats for a private key are:
- Password-encrypted PKCS #8 PEM
- PKCS #1 PEM

Alternatively, a PKCS#12 or P12 or PFX bundle containing exactly one certificate and one private key can be imported. If multiple entries of the same kind are found, only the first one would be imported.

The formats are detected automatically. Files with the `.key` file extension are usually in PEM or DER formats.


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter
import java.io.ByteArrayInputStream
import java.io.File
import java.io.FileInputStream
import java.io.InputStreamReader
import java.security.KeyFactory
import java.security.KeyStore
import java.security.PrivateKey
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
Expand Down Expand Up @@ -188,6 +190,70 @@ private fun ByteArray.tryToConvertPemToDer(startLine: String, endLine: String):
}
}

fun ClientCertificateKeyPair.Companion.importFrom(bundleFile: File, keyStorePassword: String, keyPassword: String): ClientCertificateKeyPair {
if (!bundleFile.canRead()) {
throw IllegalArgumentException("File ${bundleFile.name} cannot be read.")
}

val store = KeyStore.getInstance("PKCS12")
store.load(FileInputStream(bundleFile), keyStorePassword.toCharArray())

var cert: X509Certificate? = null
var privateKey: PrivateKey? = null

val e = store.aliases()
while (e.hasMoreElements() && (cert == null || privateKey == null)) {
val alias = e.nextElement()
if (store.isCertificateEntry(alias) && cert == null) {
cert = store.getCertificate(alias) as? X509Certificate
} else if (store.isKeyEntry(alias) && privateKey == null) {
cert = store.getCertificate(alias) as? X509Certificate
privateKey = try {
store.getKey(alias, keyPassword.toCharArray()) as? PrivateKey
} catch (e: Throwable) {
log.w(e) { "The key with alias $alias cannot be retrieved." }
null
}
}
}

if (cert == null) {
throw RuntimeException("No certificate was found.")
}
if (privateKey == null) {
throw RuntimeException("No key was retrieved.")
}

val now = KInstant.now()
return ClientCertificateKeyPair(
id = uuidString(),
certificate = ImportedFile(
id = uuidString(),
name = cert.subjectX500Principal.getName(X500Principal.RFC1779) +
"\nExpiry: ${
KZonedInstant(
cert.notAfter.time,
KZoneOffset.local()
).format(KDateTimeFormat.ISO8601_DATETIME.pattern)
}",
originalFilename = bundleFile.name,
createdWhen = now,
isEnabled = true,
content = cert.encoded,
),
privateKey = ImportedFile(
id = uuidString(),
name = "Private Key",
originalFilename = bundleFile.name,
createdWhen = now,
isEnabled = true,
content = privateKey.encoded, // store decrypted bytes
),
createdWhen = now,
isEnabled = true,
)
}

fun parseCaCertificates(bytes: ByteArray) : List<X509Certificate> {
val certBytes = bytes.tryToConvertPemToDer(startLine = "-----BEGIN PKCS7-----", endLine = "-----END PKCS7-----")
return CertificateFactory.getInstance("X.509")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,14 @@ 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.importCaCertificates
import com.sunnychung.application.multiplatform.hellohttp.util.importFrom
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.copyWithout
import com.sunnychung.application.multiplatform.hellohttp.util.formatByteSize
import com.sunnychung.application.multiplatform.hellohttp.util.importCaCertificates
import com.sunnychung.application.multiplatform.hellohttp.util.importFrom
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
Expand Down Expand Up @@ -572,36 +572,93 @@ fun CertificateEditorView(
@Composable
fun CertificateKeyPairImportForm(modifier: Modifier = Modifier, onAddItem: (ClientCertificateKeyPair) -> Unit) {
val headerColumnWidth = 160.dp
val colours = LocalColor.current

var certFile by remember { mutableStateOf<File?>(null) }
var keyFile by remember { mutableStateOf<File?>(null) }
var bundleFile by remember { mutableStateOf<File?>(null) }
var bundleFilePassword by remember { mutableStateOf("") }
var keyFilePassword by remember { mutableStateOf("") }
var fileChooser by remember { mutableStateOf(CertificateKeyPairFileChooserType.None) }

Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
AppText(text = "Certificate", modifier = Modifier.width(headerColumnWidth))
AppTextButton(
text = certFile?.name ?: "Choose a File in DER/PEM/P7B/CER/CRT format",
onClick = { fileChooser = CertificateKeyPairFileChooserType.Certificate },
modifier = Modifier.testTag(buildTestTag(
TestTagPart.EnvironmentSslClientCertificates,
TestTagPart.ClientCertificate,
TestTagPart.FileButton,
)!!)
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
AppText(text = "Private Key", modifier = Modifier.width(headerColumnWidth))
AppTextButton(
text = keyFile?.name ?: "Choose a File in PKCS#1/PKCS#8 DER/PEM format",
onClick = { fileChooser = CertificateKeyPairFileChooserType.PrivateKey },
modifier = Modifier.testTag(buildTestTag(
TestTagPart.EnvironmentSslClientCertificates,
TestTagPart.PrivateKey,
TestTagPart.FileButton,
)!!)
)
Box(modifier = Modifier.height(IntrinsicSize.Max)) {
val headerColumnWidth = headerColumnWidth - 25.dp
Box(
modifier = Modifier
.padding(12.dp)
.border(width = 1.dp, color = colours.placeholder, RectangleShape)
.fillMaxWidth()
.fillMaxHeight()
) {}
Column {
Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(start = 25.dp, end = 25.dp, top = 25.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
AppText(text = "Certificate", modifier = Modifier.width(headerColumnWidth))
AppTextButton(
text = certFile?.name ?: "Choose a File in DER/PEM/P7B/CER/CRT format",
onClick = { fileChooser = CertificateKeyPairFileChooserType.Certificate },
modifier = Modifier.testTag(
buildTestTag(
TestTagPart.EnvironmentSslClientCertificates,
TestTagPart.ClientCertificate,
TestTagPart.FileButton,
)!!
)
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
AppText(text = "Private Key", modifier = Modifier.width(headerColumnWidth))
AppTextButton(
text = keyFile?.name ?: "Choose a File in PKCS#1/PKCS#8 DER/PEM format",
onClick = { fileChooser = CertificateKeyPairFileChooserType.PrivateKey },
modifier = Modifier.testTag(
buildTestTag(
TestTagPart.EnvironmentSslClientCertificates,
TestTagPart.PrivateKey,
TestTagPart.FileButton,
)!!
)
)
}
}
Box(contentAlignment = Alignment.Center) {
Box(
modifier = Modifier
.padding(horizontal = 12.dp)
.height(1.dp)
.fillMaxWidth()
.background(colours.placeholder)
) {}
AppText("or", modifier = Modifier.background(colours.background).padding(horizontal = 12.dp, vertical = 4.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(start = 25.dp, end = 25.dp, bottom = 25.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
AppText(text = "Bundle", modifier = Modifier.width(headerColumnWidth))
AppTextButton(
text = bundleFile?.name ?: "Choose a File in PKCS#12/P12/PFX format",
onClick = { fileChooser = CertificateKeyPairFileChooserType.Bundle },
modifier = Modifier.testTag(
buildTestTag(
TestTagPart.EnvironmentSslClientCertificates,
TestTagPart.Bundle,
TestTagPart.FileButton,
)!!
)
)
}

Row(verticalAlignment = Alignment.CenterVertically) {
AppText(text = "Key Store Password", modifier = Modifier.width(headerColumnWidth))
AppTextField(
value = bundleFilePassword,
onValueChange = { bundleFilePassword = it },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.defaultMinSize(minWidth = 200.dp)
)
}
}
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
AppText(text = "Private Key Password", modifier = Modifier.width(headerColumnWidth))
Expand All @@ -612,17 +669,24 @@ fun CertificateKeyPairImportForm(modifier: Modifier = Modifier, onAddItem: (Clie
modifier = Modifier.defaultMinSize(minWidth = 200.dp)
)
}
Row {
Spacer(modifier = Modifier.width(4.dp))
Row(modifier = Modifier.align(Alignment.End).padding(top = 4.dp, start = 4.dp, end = 4.dp)) {
AppTextButton(
text = "Import this Certificate-Key Pair",
onClick = {
val parsed = try {
if (certFile == null) throw IllegalArgumentException("Please select a certificate file.")
if (keyFile == null) throw IllegalArgumentException("Please select a private key file.")
if (bundleFile == null) {
if (certFile == null && keyFile != null) throw IllegalArgumentException("Please select a certificate file.")
if (keyFile == null && certFile != null) throw IllegalArgumentException("Please select a private key file.")
if (keyFile == null && certFile == null) throw IllegalArgumentException("Please select a bundle file or a certificate and private key file.")
ClientCertificateKeyPair.importFrom(
certFile = certFile!!,
keyFile = keyFile!!,
keyPassword = keyFilePassword
)
}
ClientCertificateKeyPair.importFrom(
certFile = certFile!!,
keyFile = keyFile!!,
bundleFile = bundleFile!!,
keyStorePassword = bundleFilePassword,
keyPassword = keyFilePassword
)
} catch (e: Throwable) {
Expand All @@ -640,16 +704,27 @@ fun CertificateKeyPairImportForm(modifier: Modifier = Modifier, onAddItem: (Clie
}

if (fileChooser != CertificateKeyPairFileChooserType.None) {
FileDialog(state = rememberFileDialogState(), title = "Choose a DER file") {
FileDialog(state = rememberFileDialogState(), title = "Choose a file") {
val currentFileChooser = fileChooser
fileChooser = CertificateKeyPairFileChooserType.None
if (!it.isNullOrEmpty()) {
when (currentFileChooser) {
CertificateKeyPairFileChooserType.None -> {
log.w { "currentFileChooser is '$currentFileChooser' for result file ${it.first().absolutePath}" }
}
CertificateKeyPairFileChooserType.Certificate -> certFile = it.first()
CertificateKeyPairFileChooserType.PrivateKey -> keyFile = it.first()
CertificateKeyPairFileChooserType.Certificate -> {
certFile = it.first()
bundleFile = null
}
CertificateKeyPairFileChooserType.PrivateKey -> {
keyFile = it.first()
bundleFile = null
}
CertificateKeyPairFileChooserType.Bundle -> {
bundleFile = it.first()
certFile = null
keyFile = null
}
}
}
}
Expand Down Expand Up @@ -860,7 +935,7 @@ fun ImportUserFileForm(modifier: Modifier = Modifier, onImportFile: (ImportedFil
}

private enum class CertificateKeyPairFileChooserType {
None, Certificate, PrivateKey
None, Certificate, PrivateKey, Bundle
}

private enum class EnvironmentEditorTab {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ enum class TestTagPart {
EnvironmentDisableSystemCaCertificates,
ClientCertificate,
PrivateKey,
Bundle,
CreateButton,
ListItemLabel,
Inherited,
Expand Down
Binary file not shown.
Empty file.
Binary file not shown.

0 comments on commit ca7bbef

Please sign in to comment.