让 Ktor 支持使用 PEM 格式的 HTTPS 证书
本文最后更新于 860 天前,其中的信息可能已经有所发展或是发生改变。

简介

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")

读取公钥

首先需要读取公钥,公钥需要包括中间证书,也就是证书链。

Example image of certificate chain
证书链(Certificate chain)

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 起始头的类型,读取为具体的对象并返回,所以要检查他的返回类型。

Preset parser in pemparser
readObject 的 parsers 是预先设置好的

而 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

参考资料:

上一篇
下一篇