如何在Android实现Thrift传输加密

1,060 阅读5分钟

欢迎到原发地址点赞Thrift SSL

说到网络传输加密,首选肯定是https,加密协议完善方便,案例多而完整。
但是有时无可奈何也需要对内网调用的RPC进行加密,其实也是加ssl,但是Thrift的案例实在太少,大多没什么参考价值。
所以在给TCP添加ssl这个方向上进行探索。既然都使用ssl了,首先得有证书,公钥私钥等必备的材料,下文就是记录这个探索的过程。

方案验证

thrift的加密demo
gitee.com/liezh/sslth…

证书签发

私自签发,作为Java开发者只要使用java自带得keytool就可以了,由于公司里tomcat也需要加证书,所以顺便签发一个keystore,那么都能公用了。

  1. 签发keystore
keytool -genkeypair -alias certificatekey -keyalg RSA -validity 365 -keystore /work/key/tomcat.keystore
  1. 使用openssl导出公钥/私钥
# 2导出公钥证书,cer是windows的标准
keytool -export -alias certificatekey -keystore /work/key/tomcat.keystore -rfc -file sslthrift.cer
# 3keystore转换为p12
keytool -importkeystore -srckeystore /work/key/tomcat.keystore -destkeystore /work/key/tomcat.p12 -deststoretype PKCS12
# 4使用openssl生成.crt
openssl pkcs12 -in /work/key/tomcat.p12 -nokeys -out /work/key/sslthrift.crt
# 5使用openssl生成.key
openssl pkcs12 -in /work/key/tomcat.p12 -nocerts -nodes -out /work/key/sslthrift.key
  1. 把公钥导入生成truststore或者bks

由于bks是需要三方的签证类库bcprov提供的,所以先下载到本地,放到${JAVA_HOME}/lib/目录下
gitee.com/liezh/sslth…

# 使用bcprov对公钥进行签发
keytool -importcert -v -trustcacerts -alias jsnet \
-file /work/key/sslthrift.cer -keystore /work/key/sslthrift.bks \
-storetype BKS -providerclass org.bouncycastle.jce.provider.BouncyCastleProvider \
-providerpath /work/java/jdk1.8.0_261/lib/bcprov-jdk15on-1.52.0.jar -storepass 123456

# 把公钥添加添加到truststore中签发
keytool -importcert  -keystore sslthrift.truststore \
-alias localhost --file /work/key/sslthrift.crt -storepass 123456

其实一开始我是不知道需要用上bks的,是因为后面Android不支持使用SunX509标准的JKS证书。会报如下错误: java.security.KeyStoreException: JKS not found java.security.KeyStoreException: SunX509 not found 因为jks是Oracle Java发行版的标准,使用的证书标准是sun自定义的SunX509,证书类型支持JKS,是Java的keytools证书工具支持的证书私钥格式。使用的是jre的类库支持,而Android就用不上jre,只会使用到了jdk的api辅助开发。

所以就需要使用BKS的证书类型,使用bcprov库进行签名,加密解密。所以Android开发不能看着是写java就以为java中理所当然的类库都用得上,有时候就是这种基础的签名算法不支持,才想不通。 ​
想通为什么找不到JKS的支持的原因,解决的方式就简单了,既然Thrift默认使用的jre支持的SunX509标准的JKS不能用,那就换一种在Android上能用的X509开放标准的BKS。

编码验证

服务端

其实Thrif服务端的代码不需要修改,还是原来的启动方式。

public static void main(String[] args) {
	MobileServiceImpl handler = new MobileServiceImpl();
	MobileService.Processor processor = new MobileService.Processor(handler);
      
    TServerSocket serverTransport = new TServerSocket(port);
	// 设置协议工厂为 TCompactProtocol.Factory
	TCompactProtocol.Factory proFactory = new TCompactProtocol.Factory();
	// 多线程服务模型
	TThreadPoolServer.Args tArgs = new TThreadPoolServer.Args(serverTransport);
	tArgs.processor(processor);
	tArgs.protocolFactory(proFactory);
	tArgs.minWorkerThreads = minWorkerThreads;
	tArgs.maxWorkerThreads = maxWorkerThreads;
	tArgs.requestTimeout = requestTimeout;
	tArgs.requestTimeoutUnit = timeUnit;
	// 线程池服务模型,使用标准的阻塞式IO,预先创建一组线程处理请求。
	TServer server = new TThreadPoolServer(tArgs);
	System.out
		.println("Starting the project MobileService with TThreadPoolServer port:"
		+ port + " version:" + PropertiesUtils.getValue("VERSION") + "...");
	server.serve();
}

Nginx 代理

但是不能在直接暴露服务端口给客户端提供服务了,需要由nginx进行SSL加密后对客户端提供服务,由nginx解密后代理给Thrift MobileService,如下图:

stream {
    log_format main '$remote_addr [$time_local] '
                 '$protocol $ssl_protocol/$ssl_cipher $status $bytes_sent $bytes_received '
                 '$session_time "$upstream_addr" '
                 '"$upstream_bytes_sent" "$upstream_bytes_received" "$upstream_connect_time"';
    access_log  /work/liezh/nginx/logs/thrift_access.log  main;
    error_log  /work/liezh/nginx/logs/thrift_error.log  main;

    server {
        listen 9001 ssl;
        proxy_pass 127.0.0.1:9000;
        ssl_certificate      /home/liezh/sslthrift.crt;
        ssl_certificate_key   /home/liezh/sslthrift.key;
    }
}

客户端

前面两part其实都挺简单的,而且合乎情理,容易理解,按理来说客户端的也是这样的情况的。但是可惜的是我这里遇到的客户端不是JVM平台了。
是Android平台,这是个问题。在没有充分了解每一个平台时不要那么自信,就算在JVM平台的方案认证通过了,也不代表在其他平台可以顺顺利利。
首先编码是很简单的,在原先的客户端的基础上添加SSL的支持。

问题一:Android不能用路径的形式读取assets目录下的文件

Android没办法以路径的形式读取assets目录下的文件,只能通过AssetManager#open("sslthrift.bks")读取输入流。
但是Thrift V0.10.0版本TSSLTransportFactory.TSSLTransportParameters#setTrustStore(ksPath,ksPass)只支持路径读取,源码如下:

public class AppContext extends Application {

    private static Context instance;

    @Override
    public void onCreate() {
        super.onCreate();
        instance = getApplicationContext();
        // 在应用启动时判断证书是否安装
        if (!bksIsInstall()) {
            try {
                // 把assets目录下的证书(jks/bks)安装到sd卡目录上
                AssetUtil.putAssetsToSDCard(instance, PropertiesUtils.getStringValue("keyStore"));
            } catch (RuntimeException ignored) {
                ToastUtils.showToast(ignored.getMessage(), getContext());
            }
        }
    }

    public boolean bksIsInstall() {
        String bksPath = AppContext.getContext().getExternalFilesDir(null).getAbsolutePath()
                + File.separator
                + PropertiesUtils.getStringValue("keyStore");
        File file = new File(bksPath);
        return file.exists();
    }
    
    public static Context getContext()
    {
        return instance;
    }

}
    public User getUser() {
        try {
            /********** 改造点 ************/
            TSSLTransportFactory.TSSLTransportParameters params
                    = new TSSLTransportFactory.TSSLTransportParameters();
            String keyStore = PropertiesUtils.getStringValue("keyStore"); // keyStore=sslthrift.jks
            // 这里是事先app在启动时检测,应用目录下是否安装过证书
            // 只有在目录下安装过证书才能拿到证书的路径
            // 路径如下:/storage/emulated/0/Android/data/com.liezh.sslthriftdemo/files/sslthrift.jks
            String bksPath = AppContext.getContext().getExternalFilesDir(null).getAbsolutePath() + File.separator + keyStore;
            String keyStorePass = "123456";
            params.setTrustStore(bksPath, keyStorePass, "SunX509", "JKS");
            transport = TSSLTransportFactory.getClientSocket(thrift_qxspd_ip, thrif_qxspd_port, thrift_timeout, params);
            /********** 改造点 ************/
            
            protocol = new TCompactProtocol(transport);
            client = new MobileService.Client(protocol);

            return client.getUser("liezh", "123456");

        } catch (TException e) {
            e.printStackTrace();
        } finally {
            if (transport != null && transport.isOpen()) {
                transport.close();
            }
        }
    }

问题二:JKS和SunX509 Android平台不支持

public class TSSLTransportFactory { 
        private static SSLContext createSSLContext(TSSLTransportFactory.TSSLTransportParameters params) throws TTransportException {
        InputStream in = null;
        InputStream is = null;

        SSLContext ctx;
        try {
            ctx = SSLContext.getInstance(params.protocol);
            TrustManagerFactory tmf = null;
            KeyManagerFactory kmf = null;
            KeyStore ks;
            if (params.isTrustStoreSet) {
                // @3. 默认是PKIX的签名标准,会和我们现在用keytool的签发的SunX509标准不一致
                // 如果使用SunX509,会报 SunX509 not found
                tmf = TrustManagerFactory.getInstance(params.trustManagerType);
                // @4. TrustManagerFactory底层就是使用了java.security.KeyStore
                // 会报java.security.KeyStoreException: JKS not found
                ks = KeyStore.getInstance(params.trustStoreType);
                in = getStoreAsStream(params.trustStore);
                ks.load(in, params.trustPass != null ? params.trustPass.toCharArray() : null);
                tmf.init(ks);
            }
            
	    ...
        } catch (Exception var17) {
            throw new TTransportException("Error creating the transport", var17);
        } finally {
	    ...
        }

        return ctx;
    }

    public static class TSSLTransportParameters {
	public TSSLTransportParameters(String protocol, String[] cipherSuites, boolean clientAuth) {
    	        this.protocol = "TLS";
                // @1. 
    	        // 由于是在Android平台默认的算法是PKIX
    	        this.keyManagerType = KeyManagerFactory.getDefaultAlgorithm();
    	        this.keyStoreType = "JKS";
   		// 由于是在Android平台默认是不支持JKS的
   	  	this.trustManagerType = TrustManagerFactory.getDefaultAlgorithm();
        	this.trustStoreType = "JKS";
        	this.clientAuth = false;
        	this.isKeyStoreSet = false;
        	this.isTrustStoreSet = false;
        	if (protocol != null) {
            	   this.protocol = protocol;
        	}

        	this.cipherSuites = cipherSuites != null ? (String[])Arrays.copyOf(cipherSuites, cipherSuites.length) : null;
        	this.clientAuth = clientAuth;
    	}
        
        /**
         * @2. 
         * 如果不指定trustManagerType、trustStoreType会默认使用PKIX和JKS,
         * 是不行的Android不支持JKS
         */ 
        public void setTrustStore(String trustStore, String trustPass, String trustManagerType, String trustStoreType) {
            this.trustStore = trustStore;
            this.trustPass = trustPass;
            if (trustManagerType != null) {
                this.trustManagerType = trustManagerType;
            }
            if (trustStoreType != null) {
                this.trustStoreType = trustStoreType;
            }
            this.isTrustStoreSet = true;
        }
    }
}

看完上面配置Thrift TSSLTransportFactory配置SSL的源码,经过一番思考和百度,意识到,在Android平台不能使用keytool默认签发使用的SunX509标准的JKS。明白这点就简单了,使用别的标准签发的证书。
解决方案:使用bcprov替换jre的加密组件,作为加解密的工具,这也是Android默认支持的加解密组件。使用X509的标准签发BKS证书。如下:

# 使用bcprov对公钥进行签发
keytool -importcert -v -trustcacerts -alias jsnet \
-file /work/key/sslthrift.cer -keystore /work/key/sslthrift.bks \
-storetype BKS -providerclass org.bouncycastle.jce.provider.BouncyCastleProvider \
-providerpath /work/java/jdk1.8.0_261/lib/bcprov-jdk15on-1.52.0.jar -storepass 123456

改造客户端的SSL配置:如下:

public User getUser() {
        try {
            /********** 改造点 ************/
            TSSLTransportFactory.TSSLTransportParameters params
                    = new TSSLTransportFactory.TSSLTransportParameters();
            String keyStore = PropertiesUtils.getStringValue("keyStore"); // keyStore=sslthrift.bks
            // 这里是事先app在启动时检测,应用目录下是否安装过证书
            // 只有在目录下安装过证书才能拿到证书的路径
            // 路径如下:/storage/emulated/0/Android/data/com.liezh.sslthriftdemo/files/sslthrift.bks
            String bksPath = AppContext.getContext().getExternalFilesDir(null).getAbsolutePath() + File.separator + keyStore;
            String keyStorePass = "123456";
            params.setTrustStore(bksPath, keyStorePass, "X509", "BKS");
            transport = TSSLTransportFactory.getClientSocket(thrift_qxspd_ip, thrif_qxspd_port, thrift_timeout, params);
            /********** 改造点 ************/
            
            protocol = new TCompactProtocol(transport);
            client = new MobileService.Client(protocol);

            return client.getUser("liezh", "123456");

        } catch (TException e) {
            e.printStackTrace();
        } finally {
            if (transport != null && transport.isOpen()) {
                transport.close();
            }
        }
    }

验证

运行后端服务,重启nginx服务,启动app。
nginx的日志显示加密没有问题,确实有使用TLS1.2加密协议,对数据进行加密,并解密后转发到9000端口上的Thrift服务端。如下图日志显示:
image.png
查看app是否启动时是否安装证书成功,如下图:
image.png

总结

虽然整个过程里开发难度不大,但是在不知道Android平台不支持SunX509标准和JKS的情况下,确实存在很长时间的困惑期,明明java是应该支持JKS的,这是java默认的实现就支持的。
到了Android似乎就失效了,运行时直接报JKS not found,找不到。最后还是经过阅读Thrift源码,明白了Thrift针对Java平台默认是支持JKS的,但是Android平台显然不属于Java平台那方,Google使用java作为开发语言,但是提供的运行时基类和JVM平台是不一样的,所以jre上的一部分组件,在Android是不能用的,例如在本文里的加密组件是不能使用jre的版本的,也就为什么Android平台不支持SunX509标准和JKS证书的根本原因。
所以不能用的JVM类库就找替代方案,加解密就可以用bcprov-jdk15on-1.52.0.jar。
stackoverflow.com/questions/3…
image.png