OKHttp、Tomcat 自制证书双向认证

HTTPS 原理

传统的 HTTP 协议名为“超文本传输协议”,其客户端和服务器的交互方式为:

在数据传输过程中,需要经过无数的终端,因为在传输过程使用的是明问传输,所以在中间会很容易被监听、截获或者修改:

这就是所谓的“中间人攻击”,在安全大于天的今天,这种情况显然是不被允许的,如何防范这种情况的发生呢?

既然 HTTP 在网络传输中是明文传输的,谁拿到都能看得懂,那我们将传输数据加密传输不就行了么。数据在传输过程中都是加密过后的内容,而客户端和服务器根据手中的密钥来对加密信息进行解密之后来读取信息,即使中间有人截获了,也不会知道我们传输的是什么,我们的安全目的也就达到了,这样 HTTPS 应运而生。

上面提到了客户端和服务器需要对传输内容进行加密解密,自然就需要双方都需要有一个相同的密钥,只要这个密钥不泄露,密文就不会被破解,那么问题又随之而来,客户端是遍布全世界的,如何保证随机的客户端和服务器都拥有相同的密钥呢?非对称加密可以完美解决这个问题:

  1. 客户端生成自己的密钥
  2. 使用服务器的公钥对生成的密钥进行加密,这个公钥加密后的内容只能通过服务器的私钥要进行解密
  3. 服务器收到客户端传递过来的加密内容,使用自己的私钥进行解密,获取到客户端生成的密钥

至此,客户端和服务器都拥有的相同的密钥,就可以对传输内容进行加密解密了。

这个时候又一个问题随之二来,服务器的公钥是对外公布的,那么如何保证服务器的公钥不被篡改呢?这时候该 CA机构 来出场摆平了。

CA 机构规定,每个“好网站”都需要有一个“良民证”,只要是这个良民证的网站,就可以随便出入大门,如果没有良民证或者良民证被发现是假的,就会被特务跟踪。

每个网站需要像 CA 机构去申请一个“良民证”—— CA 证书,申请时需要携带自己的身份证明:网站公钥。CA 机构根据你的申请信息会将你的公钥打包成一个“良民证”—— CA 证书,这样你将良民证(CA 证书)携带在身上(部署在服务器),就可以证明自己的清白了。

客户端在访问我们的网站时,服务器首先会将这个证书返回给浏览器,操作系统会集成所有 CA 机构的公钥,然后浏览器使用 CA 机构的公钥对证书进行解密,从而得到我们网站的公钥和一些其他身份信息,如何证书以及其他信息是正确的,那么会提示用户这是一个合法网站,接下来就是双方的加密通信了;如果内置的所有 CA 公钥都无法解开这个证书,或者解开之后发现证书信息是错误的或者是过期的,则会做出响应的提示,提示用户这个网站是危险的。

除了根据 CA 机构申请证书之外,我们也可以自己创建证书,因为我们自己创建了证书,系统没有提前内置公钥,所以浏览器也会提醒这个网站是有问题的,但这并不妨碍我们进行加密传输,接下来我们实践一下。

相关格式说明

  • JKS 和 KeyStore:数字证书库。JKS 里有 KeyEntry 和 CertEntry,在库里的每个 Entry 都是靠别名(alias)来识别的。
  • P12:是 PKCS12 的缩写。同样是一个存储私钥的证书库,由 .jks 文件导出的,用户在 PC 平台安装,用于标示用户的身份。
  • CER:俗称数字证书,目的就是用于存储公钥证书,任何人都可以获取这个文件 。
  • BKS:由于Android平台不识别 .keystore 和 .jks 格式的证书库文件,因此 Android 平台引入一种的证书库格式:BKS。

Tomat 服务器搭建

上面讲的 HTTPS 实际上是一个单向的认证,也就是说,客户端只对服务器的合法性进行验证,而服务器并不验证客户端,这满足了日常的访问需求。但在一些特殊领域,例如金融、军事等等保密要求更加严格的领域,服务器并不完全对所有人开放,所以也就需要对客户端的合法性进行验证,这就是双向认证。

单向认证

生成证书
  1. keytool -genkey -alias server -keyalg RSA -keystore server.jks -validity 3600 -storepass 111111

    上面命令执行之后,需要输入一系列证书信息

    会生成我们网站的私钥文件:server.jks

    需要注意的是:

    • 您的名字与姓氏是什么? 需要输入服务器的 IP 或者域名,否则会出错
    • 使用的是 RSA 算法
    • 有效期是 3600 天
    • 密码是 111111
  2. keytool -export -alias server -file server.cer -keystore server.jks -storepass 111111

这个命令是从私钥中导出证书:server.cer,这个证书中包含了我们网站的一些信息(上一条命令中我们输入的那些)以及网站的公钥

Tomcat 部署

将上面生成的私钥文件 server.jks 上传到服务器,修改 apache-tomcat-8.5.47/conf/server.xml 文件,添加以下标签:

<Connector 
           SSLEnabled="true" 
           acceptCount="100" 
           clientAuth="false"
           disableUploadTimeout="true" 
           enableLookups="true" 
           keystoreFile="/***********/server.jks"     //这个就是我们的私钥文件
           keystorePass="111111"     //私钥文件密码
           maxSpareThreads="75"
           maxThreads="200" 
           minSpareThreads="5" 
           port="8443"
           protocol="org.apache.coyote.http11.Http11NioProtocol" 
           scheme="https"
           secure="true" 
           sslProtocol="TLS"
           />

重启 Tomcat

Android 端访问

将上面生成的 server.cer 放到项目的 assets 目录中待用:

            try {
                val inputStream: InputStream = assets.open("server.cer")
                val trustManager: X509TrustManager = trustManagerForCertificates(inputStream)
                val sslContext = SSLContext.getInstance("TLS")
                sslContext.init(null, arrayOf<TrustManager>(trustManager), null)
                val sslSocketFactory = sslContext.socketFactory
                val request: Request = Request.Builder()
                    .url("https://www.lifekeeper.top:8443")
                    .build()
                val client: OkHttpClient = OkHttpClient.Builder()
                    .sslSocketFactory(sslSocketFactory, trustManager)
                    .hostnameVerifier(HostnameVerifier { hostname, _ ->
                        "www.lifekeeper.top" == hostname
                    })
                    .build()
                client.newCall(request).enqueue(object : Callback {
                    override fun onFailure(call: Call, e: IOException) {
                        e.printStackTrace()
                    }
                    override fun onResponse(call: Call, response: Response) {
                        Log.d(TAG, response.body!!.string())
                    }
                })
            } catch (e: NoSuchAlgorithmException) {
                e.printStackTrace()
            } catch (e: IOException) {
                e.printStackTrace()
            } catch (e: GeneralSecurityException) {
                e.printStackTrace()
            }

需要注意的是,创建 OkHttpClient 时,一定要添加 hostnameVerifier 方法,可以对域名做一下验证,或者干脆直接返回 true,如果没有添加这个方法,则会抛出 javax.net.ssl.SSLPeerUnverifiedException: Hostname xxx not verifie

    @Throws(GeneralSecurityException::class)
    private fun trustManagerForCertificates(`in`: InputStream): X509TrustManager {
        val certificateFactory: CertificateFactory = CertificateFactory.getInstance("X.509")
        val certificates: Collection<Certificate?> = certificateFactory.generateCertificates(`in`)
        require(!certificates.isEmpty()) { "expected non-empty set of trusted certificates" }

        // Put the certificates a key store.
        val password = "password".toCharArray() // Any password will work.
        val keyStore: KeyStore = newEmptyKeyStore(password)
        for ((index, certificate) in certificates.withIndex()) {
            val certificateAlias = (index).toString()
            keyStore.setCertificateEntry(certificateAlias, certificate)
        }

        val keyManagerFactory: KeyManagerFactory = KeyManagerFactory.getInstance(
            KeyManagerFactory.getDefaultAlgorithm()
        )
        keyManagerFactory.init(keyStore, password)
        val trustManagerFactory: TrustManagerFactory = TrustManagerFactory.getInstance(
            TrustManagerFactory.getDefaultAlgorithm()
        )
        trustManagerFactory.init(keyStore)
        val trustManagers: Array<TrustManager> = trustManagerFactory.trustManagers
        check(!(trustManagers.size != 1 || trustManagers[0] !is X509TrustManager)) {
            ("Unexpected default trust managers:"
                    + trustManagers.contentToString())
        }
        return trustManagers[0] as X509TrustManager
    }

    @Throws(GeneralSecurityException::class)
    private fun newEmptyKeyStore(password: CharArray): KeyStore {
        return try {
            val keyStore: KeyStore =
                KeyStore.getInstance(KeyStore.getDefaultType()) // 这里添加自定义的密码,默认
            val `in`: InputStream? = null // By convention, 'null' creates an empty key store.
            keyStore.load(`in`, password)
            keyStore
        } catch (e: IOException) {
            throw AssertionError(e)
        }
    }

单向访问就是这么简单。

双向认证

对于一些特殊的需求,客户端不光需要认证服务端,服务端也确保客户端也是合法的,这就需要双向认证了。

生成证书

和单向认证不同的是,因为需要双向的认证合法性,所以需要生成服务端和客户端两套证书,并相互信任:

生成服务器证书

keytool -genkeypair -v -alias server -keyalg RSA -validity 3650 -keystore server.keystore  -storepass 111111 -keypass 111111 -dname "CN=www.lifekeeper.top,OU=lifekeeper,O=lifekeeper,L=beijing,ST=beijing,C=cn"

导出服务器端证书

keytool -exportcert -alias server  -keystore server.keystore  -file server.cer  -storepass 111111

将服务器端证书导入信任证书

keytool -importcert -alias serverca  -keystore server_trust.keystore  -file server.cer  -storepass 111111

生成客户端证书

keytool -genkeypair -v -alias client -dname "CN=lifekeeper client" -keyalg RSA -validity 3650 -keypass 111111 -keystore client.p12 -storepass 111111 -storetype PKCS12

导出客户端证书

keytool -exportcert -alias client -file client.cer -keystore client.p12 -storepass 111111 -storetype PKCS12

将客户端证书导入到服务器信任证书库

keytool -importcert -alias clientca  -keystore server_trust.keystore  -file client.cer  -storepass 111111
Tomcat 部署

server.keystoreserver_trust.keystore 都上传到服务器,修改 apache-tomcat-8.5.47/conf/server.xml 文件,添加以下标签:

    <Connector 
        SSLEnabled="true" 
        acceptCount="100" 
        clientAuth="true" 
        truststoreFile="/home/apache-tomcat-8.5.47/conf/server_trust.keystore"
        disableUploadTimeout="true" 
        enableLookups="true" 
        keystoreFile="/home/apache-tomcat-8.5.47/conf/server.keystore" 
        keystorePass="111111" 
        maxSpareThreads="75"
        maxThreads="200" 
        minSpareThreads="5" 
        port="8443"
        protocol="org.apache.coyote.http11.Http11NioProtocol" 
        scheme="https"
        secure="true" 
        sslProtocol="TLS"
         />

重启 Tomcat

Android 端访问

Java平台可以识别 keystore 的证书文件,而 Android 平台只识别 bks 格式的证书文件,所以我们需要将 client.p12 文件转换成 bks 格式的,下载转换工具:

portecle 下载地址

解压之后,使用 java -jar portecle.jar 命令打开软件,然后按照下面的步骤执行转换:

转换过程中有可能会出错,提示:

java.security.KeyStoreException:java.io.IOException:Error initialising store of key store:
java.security.invalidKeyException:lllegal key size

这是因为

Illegal key size or default parameters 是指密钥长度受限制,
java运行时环境读到的是受限的policy文件。
policy文件位于${java_home}/jre/lib/security 目录下。
这种限制是因为美国对软件出口的控制。

解决办法如下:

  • jce8 下载 下载文件
  • 替换 jdk\jre\lib\security 目录下的相同文件
  • 重启软件

然后将转换后的 client.bksserver.cer 文件放到 Android 项目的 assets 目录待用。

Android 代码:

工具类:

HttpsSSLParams.kt

import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509TrustManager

class HttpsSSLParams {
    var sSLSocketFactory: SSLSocketFactory? = null
    var trustManager: X509TrustManager? = null
}

HttpsTrustManager.kt

import java.security.KeyStore
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager

class HttpsTrustManager(localTrustManager: X509TrustManager) : X509TrustManager {
    private var defaultTrustManager: X509TrustManager? = null
    private var localTrustManager: X509TrustManager? = localTrustManager

    init {
        val var4 = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
        var4.init(null as KeyStore?)
        defaultTrustManager = chooseTrustManager(var4.trustManagers)
    }

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

    override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
        try {
            defaultTrustManager!!.checkServerTrusted(chain, authType)
        } catch (ce: CertificateException) {
            localTrustManager!!.checkServerTrusted(chain, authType)
        }
    }

    override fun getAcceptedIssuers(): Array<X509Certificate?> {
        return arrayOfNulls(0)
    }

    private fun chooseTrustManager(trustManagers: Array<TrustManager>): X509TrustManager? {
        for (trustManager in trustManagers) {
            if (trustManager is X509TrustManager) {
                return trustManager
            }
        }
        return null
    }
}

HttpsUnSafeTrustManager.kt

import java.security.cert.X509Certificate
import javax.net.ssl.X509TrustManager

class HttpsUnSafeTrustManager : X509TrustManager {
    override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
    }

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

    override fun getAcceptedIssuers(): Array<X509Certificate> {
        return arrayOf()
    }
}

HttpsUtil.kt

import java.io.IOException
import java.io.InputStream
import java.security.*
import java.security.cert.CertificateException
import java.security.cert.CertificateFactory
import javax.net.ssl.*

class HttpsUtil {
    companion object {
        fun getSslSocketFactory(
            certificates: Array<InputStream>,
            bksFile: InputStream?,
            password: String?
        ): HttpsSSLParams {
            val sslParams = HttpsSSLParams()
            return try {
                val trustManagers = prepareTrustManager(*certificates)
                val keyManagers = prepareKeyManager(bksFile, password)
                val sslContext = SSLContext.getInstance("TLS")
                val trustManager: X509TrustManager
                trustManager = if (trustManagers != null) {
                    HttpsTrustManager(chooseTrustManager(trustManagers)!!)
                } else {
                    HttpsUnSafeTrustManager()
                }
                sslContext.init(keyManagers, arrayOf<TrustManager>(trustManager), null)
                sslParams.sSLSocketFactory = sslContext.socketFactory
                sslParams.trustManager = trustManager
                sslParams
            } catch (e: NoSuchAlgorithmException) {
                e.printStackTrace()
                throw AssertionError(e)
            } catch (e: KeyManagementException) {
                e.printStackTrace()
                throw AssertionError(e)
            } catch (e: KeyStoreException) {
                e.printStackTrace()
                throw AssertionError(e)
            }
        }

        private fun prepareTrustManager(vararg certificates: InputStream): Array<TrustManager>? {
            if (certificates.isEmpty()) return null
            try {
                val certificateFactory = CertificateFactory.getInstance("X.509")
                val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
                keyStore.load(null)
                for ((index, certificate) in certificates.withIndex()) {
                    val certificateAlias = (index).toString()
                    keyStore.setCertificateEntry(
                        certificateAlias,
                        certificateFactory.generateCertificate(certificate)
                    )
                    try {
                        certificate.close()
                    } catch (e: IOException) {
                        e.printStackTrace()
                    }
                }
                val trustManagerFactory: TrustManagerFactory =
                    TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
                trustManagerFactory.init(keyStore)
                return trustManagerFactory.trustManagers
            } catch (e: NoSuchAlgorithmException) {
                e.printStackTrace()
            } catch (e: CertificateException) {
                e.printStackTrace()
            } catch (e: KeyStoreException) {
                e.printStackTrace()
            } catch (e: IOException) {
                e.printStackTrace()
            }
            return null
        }

        private fun prepareKeyManager(
            bksFile: InputStream?,
            password: String?
        ): Array<KeyManager?>? {
            try {
                if (bksFile == null || password == null) return null
                val clientKeyStore = KeyStore.getInstance("BKS")
                clientKeyStore.load(bksFile, password.toCharArray())
                val keyManagerFactory: KeyManagerFactory =
                    KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
                keyManagerFactory.init(clientKeyStore, password.toCharArray())
                return keyManagerFactory.getKeyManagers()
            } catch (e: NoSuchAlgorithmException) {
                e.printStackTrace()
            } catch (e: UnrecoverableKeyException) {
                e.printStackTrace()
            } catch (e: CertificateException) {
                e.printStackTrace()
            } catch (e: KeyStoreException) {
                e.printStackTrace()
            } catch (e: IOException) {
                e.printStackTrace()
            }
            return null
        }

        private fun chooseTrustManager(trustManagers: Array<TrustManager>): X509TrustManager? {
            for (trustManager in trustManagers) {
                if (trustManager is X509TrustManager) {
                    return trustManager
                }
            }
            return null
        }
    }
}

OKHttp 使用如下:

val request: Request = Request.Builder()
                .url("https://www.lifekeeper.top:8443")
                .build()
            val factory = HttpsUtil.getSslSocketFactory(
                arrayOf(assets.open("server.cer")),
                assets.open("client.bks"),
                "111111"
            )
            val client: OkHttpClient = OkHttpClient.Builder()
                .connectTimeout(60 * 1000.toLong(), TimeUnit.MILLISECONDS)
                .readTimeout(5 * 60 * 1000.toLong(), TimeUnit.MILLISECONDS)
                .writeTimeout(5 * 60 * 1000.toLong(), TimeUnit.MILLISECONDS)
                .sslSocketFactory(
                    factory.sSLSocketFactory!!, factory.trustManager!!
                )
                .hostnameVerifier(HostnameVerifier { hostname, _ ->
                    "www.lifekeeper.top" == hostname
                })
                .build()
            client.newCall(request).enqueue(object : Callback {
                override fun onFailure(call: Call, e: IOException) {
                    e.printStackTrace()
                }

                override fun onResponse(call: Call, response: Response) {
                    Log.d(TAG, response.body!!.string())
                }
            })

至此,HTTPS 双向认证完成~