Android 安全连接 https(nginx 配置) 双向认证实践

Posted by afon on May 31, 2017

最近做了一个项目属于高加密级别,需要使用 https 连接,本文即是在 nginx 配置自签名 https,打开了双向认证,在 Android 客户端使用 PKCS12 类型的客户端证书和 bks 类型的受信服务器证书,本文假定你已有一台 nginx 服务器,服务器上已安装 openssl,本地开发电脑上安装有 JDK(keytool 命令所在的目录已加入到环境变量)。

在服务器端生成证书

在开始之前,需要先查看或编辑 /usr/lib/ssl/openssl.cnf 文件,这是生成证书时所需要的配置文件,如果找不到这个文件,需要自行摸索这个文件在哪里。找到 [ CA_default ] 一行,查看 dir 所指向的目录,要么更改它,否则更改脚本中 pkiDir 指向的目录为 [ CA_default ] 中 dir 指向的目录,即这两个地方指向的目录最好相同。根据实际需要,也可以更改 [ policy_match ] 一行,将其中的一项或几项配置更改为 optional。

#!/bin/sh
# create self-signed server certificate:
read -p "请输入证书的域 例如[www.example.com or 192.168.1.52]: " DOMAIN

SUBJECT="/CN=$DOMAIN"
pkiDir="/home/afon/cert/pki/CA"

mkdir -p $pkiDir/newcert

echo "--------------------------- 创建服务器证书 ---------------------------"
openssl genrsa -des3 -out server.key 2048
openssl rsa -in server.key -out server.key

echo "--------------------------- 生成 server.csr ---------------------------"
openssl req -new -subj $SUBJECT -key server.key -out server.csr

echo "--------------------------- 创建客户端证书 ---------------------------"
openssl genrsa -des3 -out client.key 2048

echo "--------------------------- 生成 client.csr ---------------------------"
openssl req -new -subj $SUBJECT -key client.key -out client.csr

echo "--------------------------- 创建根证书 ---------------------------"
openssl req -new -x509 -keyout ca.key -out ca.crt

rm -rf $pkiDir/index.txt
rm -rf $pkiDir/serial
touch $pkiDir/index.txt
touch $pkiDir/serial
echo 01 > $pkiDir/serial

echo "--------------------------- 用根证书对服务器证书和客户端证书签名 ---------------------------"
openssl ca -in server.csr -out server.crt -cert ca.crt -keyfile ca.key

rm -rf $pkiDir/index.txt
touch $pkiDir/index.txt

openssl ca -in client.csr -out client.crt -cert ca.crt -keyfile ca.key

echo "--------------------------- 导出客户端证书 ---------------------------"
openssl pkcs12 -export -clcerts -in client.crt -inkey client.key -out client.p12
# openssl pkcs12 -export -in client.crt -inkey client.key -out  client.pfx
# openssl x509 -in client.crt -out client.cer
# openssl x509 -in server.crt -out server.cer

echo "--------------------------- 完成 ---------------------------"

将以上脚本复制,保存到服务器上,文件命名例如 make_cert.sh,然后执行 chmod +x ./make_cert.sh 给它执行权限。需要特别注意最后一步导出客户端证书时,需要记下导出密码,导出密码将在 Android 代码中用到。

在执行完脚本后,会生成几个文件,需要用到的文件有 server.crt、server.key、ca.crt、client.p12。需要说明的是,我注释了三行代码,这三行代码是原文作者说明在不同客户端需使用不同的证书类型(原文中说明 电脑端的浏览器使用 client.p12 证书,Android 使用 client.pfx 证书,iOS 使用 client.cer),其它情况我不了解,仅 Android 而言也可使用 client.p12 证书。

nginx 配置

server.crt、 server.key、ca.crt 三个文件用在 nginx 的 conf 配置中:

  ssl on;
  ssl_certificate /to your dir/server.crt;
  ssl_certificate_key /to your dir/server.key;
  ssl_client_certificate /to your dir/ca.crt;
  ssl_verify_client on;

执行 nginx -t 看看配置有没有 OK,确认无误再执行 nginx -s reload 使配置生效。

Android 客户端

在 Android 客户端发起 SSL 连接进行 https 双向认证时,需要两个文件,一个是上文提到的 client.p12,这是服务器端需要验证的客户端证书,另一个是 bks 格式的客户端信任的服务器端证书,有这两个文件,才能确保没有证书的客户端将无法连接服务器,服务器端证书不会被伪造。

bks 证书生成

1 到 https://www.bouncycastle.org/latest_releases.html 网站,根据 JDK 版本,下载一个文件名含有 bcprov-ext 的 jar 文件,我当时下载的是 bcprov-ext-jdk15on-157.jar,随着时间流逝,你下载的文件名会稍有不同。

2 将上文提到的 server.crt、client.p12 下载到本地开发电脑。

3 编辑一段脚本:

#!/bin/bash

if [ "$#" -ne 3 ]; then
  echo "Usage: importcert.sh <CA cert PEM file> <bouncy castle jar> <keystore pass>"
  exit 1
fi

CACERT=$1
BCJAR=$2
SECRET=$3

TRUSTSTORE=mytruststore.bks
ALIAS=`openssl x509 -inform PEM -subject_hash -noout -in $CACERT`

if [ -f $TRUSTSTORE ]; then
    rm $TRUSTSTORE || exit 1
fi

echo "Adding certificate to $TRUSTSTORE..."
keytool -import -v -trustcacerts -alias $ALIAS \
      -file $CACERT \
      -keystore $TRUSTSTORE -storetype BKS \
      -providerclass org.bouncycastle.jce.provider.BouncyCastleProvider \
      -providerpath $BCJAR \
      -storepass $SECRET

echo ""
echo "Added '$CACERT' with alias '$ALIAS' to $TRUSTSTORE..."

4 将以上脚本复制,保存到本地开发电脑上,文件命名例如 importcert.sh,然后执行 chmod +x ./importcert.sh 给它执行权限。如果你使用的是 Windows 电脑,需要自行摸索如何执行生成 bks 证书的命令。

5 假定 bcprov-ext-jdk15on-157.jar、importcert.sh、server.crt 都是位于同一目录下,那么此时的执行命令即是 ./importcert.sh server.crt bcprov-ext-jdk15on-157.jar 123456 ,需要说明的是,123456 是将要设置的 bks 证书密码,需要记下这个密码,将在 Android 代码中用到。生成的 mytruststore.bks 即是 Android 客户端所需要的。

在 Android 代码中配置证书

将 client.p12、mytruststore.bks 复制到 res/raw 目录下,你也可根据实际情况复制到其它地方,当然读取的时候就与下面的代码不同了。

import android.content.Context;
import android.util.Log;

import java.io.InputStream;
import java.security.KeyStore;

import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManagerFactory;

/**
 * <p>SSLHelper</p>
 * <p>Date: 2017/5/29</p>
 *
 * @author afon
 */

public class SSLHelper {
    private static final String KEY_STORE_TYPE_BKS = "bks"; // 证书类型 固定值
    private static final String KEY_STORE_TYPE_P12 = "PKCS12"; // 证书类型 固定值

    private static final int KEY_STORE_CLIENT_PATH = R.raw.client; // 客户端要给服务器端认证的证书
    private static final String KEY_STORE_PASSWORD = "123456"; // 证书密码

    private static final int KEY_STORE_TRUST_PATH = R.raw.mytruststore; // 客户端验证服务器端的证书库
    private static final String KEY_STORE_TRUST_PASSWORD = "123456"; // 证书库密码

    /**
     * 获取SSLContext
     *
     * @param context 上下文
     * @return SSLContext
     */
    public static SSLSocketFactory getSSLSocketFactory(Context context) {
        try {
            // 服务器端需要验证的客户端证书
            KeyStore keyStore = KeyStore.getInstance(KEY_STORE_TYPE_P12);
            // 客户端信任的服务器端证书
            KeyStore trustStore = KeyStore.getInstance(KEY_STORE_TYPE_BKS);

            InputStream ksIn = context.getResources().openRawResource(KEY_STORE_CLIENT_PATH);
            InputStream tsIn = context.getResources().openRawResource(KEY_STORE_TRUST_PATH);
            try {
                keyStore.load(ksIn, KEY_STORE_PASSWORD.toCharArray());
                trustStore.load(tsIn, KEY_STORE_TRUST_PASSWORD.toCharArray());
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                try {
                    ksIn.close();
                } catch (Exception ignore) {
                }
                try {
                    tsIn.close();
                } catch (Exception ignore) {
                }
            }

            SSLContext sslContext = SSLContext.getInstance("TLS");
            TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
            trustManagerFactory.init(trustStore);

            KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("X509");
            keyManagerFactory.init(keyStore, KEY_STORE_PASSWORD.toCharArray());

            sslContext.init(keyManagerFactory.getKeyManagers(), trustManagerFactory.getTrustManagers(), null);
            return sslContext.getSocketFactory();

        } catch (Exception e) {
            Log.e("SSLHelper", e.getMessage(), e);
        }
        return null;
    }
}

接下来,在调用 SSLHelper.getSSLSocketFactory(context) 之后,需根据项目中采用哪种网络框架,设置 SSLSocketFactory 即可,一般会提供有 setSSLSocketFactory 这个类似的方法。例如 OkHttp 可以这样写:

OkHttpClient.Builder().sslSocketFactory(SSLHelper.getSSLSocketFactory(context))

Android 客户端密钥保护

由于两个证书的密码都是明文硬编码在 Java 文件之中,这对于具有反编译能力的人员来说,泄漏证书的可能性很大,因此需要进一步把这两个密码藏起来。


引用链接:

https://my.oschina.net/u/2457218/blog/637866

https://github.com/nelenkov/custom-cert-https/blob/master/importcert.sh

https://gist.github.com/Frank-Zhu/41e21a00df26d63cd38d