简介
Ktor 官方提供了 SSL 的支持,但是配置所使用的 HTTPS 密钥形式是 Java KeyStore,这对于 PEM 格式的证书不太友好,用户配置复杂。
为此,我在应用中编写了一种 PEM 转 KeyStore 的方法,来帮助用户直接加载 PEM 形式的 HTTPS 证书。
本文会写的比较简单一些,如果想详细了解例如证书格式之类的内容,则不建议阅读本文章(该部分已省略)。
(今年最后一篇文章~)
所需依赖
实现这种方法需要配置以下依赖项(本文将使用下列依赖讲述):
// Gradle Kotlin DSL
// Ktor HTTPS 模块
implementation("io.ktor:ktor-network-tls-certificates:$ktorVersion")
// 用于读取 PEM 证书并解析 SSL 密钥对的 Bouncy Castle 依赖项.
implementation("org.bouncycastle:bcprov-jdk15to18:1.70")
implementation("org.bouncycastle:bcpkix-jdk15to18:1.70")
读取公钥
首先需要读取公钥,公钥需要包括中间证书,也就是证书链。
PEM 格式允许多个证书在一个文件中,由于 PEM 存储在文件的格式是文本,所以只需要有分隔符就可以确定证书的范围,比如这样:
-----BEGIN CERTIFICATE-----
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
-----END CERTIFICATE-----
解析这种格式很简单,而且 Bouncy Castle 加密库也为我们提供了一个工具类:PEMParse,他可以解析并获取文件中的 PEM 对象,通过获取 PEM 对象,将证书存储内容转换成 Certificate 对象,就完成公钥的读取了。
另外,HTTPS 证书本身的格式为 X.509,Java 自带的加密库支持这个格式的加载,用 CertificateFactory 获取 X.509 的构造工厂就可以进行转换了。
fun loadCertificateChain(certChainFile: File): Array<Certificate> {
if (!certChainFile.exists() || !certChainFile.isFile) {
throw IOException("The file does not exist or the path specified is not a file.")
}
val certList = ArrayList<Certificate>()
val certFactory = CertificateFactory.getInstance("X.509")
certChainFile.bufferedReader(StandardCharsets.UTF_8).use {
val pemReader = PemReader(it)
var obj = pemReader.readPemObject()
while (obj != null) {
certList.add(certFactory.generateCertificate(obj.content.inputStream()))
obj = pemReader.readPemObject()
}
}
logger.debug { "Certificate chain length: ${certList.size}" }
return certList.toArray(emptyArray())
}
读取私钥
读取私钥这块是实现中最麻烦的一部分,HTTPS 证书的私钥是以 PKCS#1 格式存储的,这种格式在 Java 的加密库中没有太多支持,使用 Bouncy Castle 处理起来也不是那么容易。
不过即使是 PKCS#1 的存储格式,但存储到文件的形式还是 PEM 格式,我们依然能用 PEMParser 来读取它。
但是如果查看 PEMParser 的 readObject 方法具体实现,会发现它会根据 PEM 起始头的类型,读取为具体的对象并返回,所以要检查他的返回类型。
而 PKCS#1 的私钥,格式为:
-----BEGIN RSA PRIVATE KEY-----
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
-----END RSA PRIVATE KEY-----
所以他会被 RSAKeyPairParser 解析,然后返回 PEMKeyPair(至于密钥算法类型是 RSA、DSA 或者 EC,都无所谓,因为他们的解析器被套了一层 KeyPairParser,返回的都是 PEMKeyPair,详见源码【org.bouncycastle.openssl.PEMParser:191】和【 org.bouncycastle.openssl.PEMKeyPairParser:7】,由于解析这三种未加密的算法密钥,都用到了 PEMKeyPairParser,而该接口指定的返回类型为 PEMKeyPair,所以不担心其他类型)
fun loadPrivateKeyFromFile(keyFile: File, password: String? = null): PrivateKey {
if (!keyFile.exists() || !keyFile.isFile) {
throw IOException("The file does not exist or the path specified is not a file.")
}
keyFile.reader(StandardCharsets.UTF_8).use {
val privateKeyInfo: PrivateKeyInfo = when (val obj = PEMParser(it).readObject()) {
is PEMKeyPair -> {
obj.privateKeyInfo
}
is PKCS8EncryptedPrivateKeyInfo -> {
val provider = JceOpenSSLPKCS8DecryptorProviderBuilder().build(password!!.toCharArray())
obj.decryptPrivateKeyInfo(provider)
}
else -> {
throw IllegalStateException("Unsupported key type: ${obj::class.java}")
}
}
return privateKeyInfoToPrivateKey(privateKeyInfo)
}
}
但是 PEMKeyPair 不能直接获取 PrivateKey,需要进行一次转换,可以使用 JcaPEMKeyConverter 将 PrivateKeyInfo 转换成 PrivateKey。
fun privateKeyInfoToPrivateKey(info: PrivateKeyInfo): PrivateKey = JcaPEMKeyConverter().getPrivateKey(info)
这样,我们就完成对 HTTPS 密钥对的读取了。
创建临时 KeyStore 并设置到 Ktor
由于我们是转换,所以只需要临时创建一个 KeyStore 就可以了。
val keyStore = KeyStore.getInstance(type)
// 需要通过 load 进行初始化, 否则 KeyStore 不可用.
keyStore.load(null, null)
创建好 KeyStore 后,将密钥导入
// 密码如果没有的话, 空数组就可以,
// "httpsCert" 是密钥对在 KeyStore 的名称.
keyStore.setKeyEntry("httpsCert", privateKey, password?.toCharArray() ?: "".toCharArray(), fullChain)
KeyStore 就设置完成了,现在要将 KeyStore 设置到 Ktor:
// KeyStore 没有密码, 传入空数组即可.
// keyAlias 要与上面步骤的名称相同.
// privateKeyPassword 也是.
sslConnector(
keyStore = keyStore,
keyStorePassword = { "".toCharArray() },
keyAlias = "httpsCert",
privateKeyPassword = { privateKeyPassword?.toCharArray() ?: "".toCharArray() }
) {
port = config.port
}
然后就大功告成了!
源码:Github
参考资料: