Socket长连接通道加密与性能优化

1,397 阅读10分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第2天,点击查看活动详情

SSL简介

大家都知道https 比 http 更加安全。这里先简单介绍下相关原理。

http 和 htpps 区别.png

如上图所示,https主要比http多了一层ssl/tls 协议,该协议会对整个Socket通信进行加密处理。那么它是如何实现加密的呢?

我们首先来看下http协议下普通的tcp建连过程,主要体现在3次握手建连和4次挥手断连过程,具体如下图所示:

http协议tcp建连过程.png

我们再来看下https的具体建连过程,如下图所示:

https协议tcp建连过程.png

由上图可以看出:https 建连过程中主要新增加了ssl 握手协议,这里我们重点说明2点:

  1. Certificate: 服务器下发证书给客户端验证。 证书验证通过则可以进行加密通信了。
  2. ServerKeyExchange:若服务器证书信息不足,则可以通过交换公钥来实现加解密。

关于SSL/TLS 的详细介绍可以看这篇文章:一篇文章让你彻底弄懂SSL/TLS协议

上面我们简单介绍了ssl/tls 加密的原理,接下来我们看下API层面如何实现。

客户端实现SSL通道加密

Java javax.net.ssl 包提供了实现SSL(安全套接字协议)的SSLContext 对象。

我们可以基于 SSLContext 方便的编写网络程序。

1. SSLContext 对象获取

SSLContext 提供了多个静态方法获取。这里我们一般用下面方式获取。

SSLContext sslContext = SSLContext.getInstance("TLSv1.2");

注意:参数表示使用何种协议,TLSv1.2 表示 TLS v1.2 版本协议,为什么是因为1.2版本呢?因为1.2之前的版本已经证明不安全,不能继续用了,目前行业内基本都是用的TLSv1.2 版本的协议。

2. SSLContext 初始化

SSLContext 提供了初始化方法init。具体如下:

void init(KeyManager[] km, TrustManager[] tm, SecureRandom random)

下面具体说明下其参数含义:

  1. km :KeyManager主要用于密钥管理。负责客户端的密钥的生成和保存等管理工作。
  2. tm :信任证书管理者。当要验证服务器 或者 客户端证书时,就是通过该tm负责的。
  3. random : 随机参数,主要用于协助km生成密钥使用。

这么说可能比较抽象,我们结合 SSL 握手过程看就比较清晰了,具体如下图所示:

keyManager和TrustManager作用.png

由上图可以看出:

  • 当服务器证书下发给客户端时,主要就是通过 TrustManager 接口来实现证书校验的。
  • 若是要生成密钥并交换则是通过KeyManager来完成的。

不得不说SDK 封装的太好了,早将协议的自定义部分设计好了接口,只待开发者实现,我们经常提到的基于接口编程说的就是这类场景了,大家可以借鉴下。

3. 自定义TrustManager 来验证服务器证书

注意 TrustManager 只是一个标识接口,它并没有定义任何接口方法,我们在使用时一般是实现它的子接口 X509TrustManager。 X509TrustManager 提供了下面3个方法:

//校验客户端证书,一般服务器需要检验客户端证书的时候就在该方法中校验
void checkClientTrusted(X509Certificate[] chain, String authType);

//校验服务器证书,一般时客户端收到服务器证书后在此方法中进行校验
void checkServerTrusted(X509Certificate[] chain, String authType);

//获取信任的证书列表
X509Certificate[] getAcceptedIssuers();

下面参考我们给出的一个实现案例:

class CustomX509TrustManager implements X509TrustManager {

    private static final String TAG = "CustomX509TrustManager";
    //合法的主体, xx改成你们公司的主体信息
    private static final String VALID_SUBJECT_1 = "*.xx.com.cn";

    //15天
    private static final long FIFTEEN_DAY = TimeUtils.ONE_DAY * 15;

    //系统默认的TrustManager, 这个一定需要有。
    private X509TrustManager mSystemDefaultTrustManager;

    public CustomX509TrustManager() {
        mSystemDefaultTrustManager = systemDefaultTrustManager();
    }

    private X509TrustManager systemDefaultTrustManager() {
        try {
            TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(
                TrustManagerFactory.getDefaultAlgorithm());
            trustManagerFactory.init((KeyStore) null);
            TrustManager[] trustManagers = trustManagerFactory.getTrustManagers();
            if (trustManagers.length != 1 || !(trustManagers[0] instanceof X509TrustManager)) {
                throw new IllegalStateException("Unexpected default trust managers:"
                    + Arrays.toString(trustManagers));
            }
            return (X509TrustManager) trustManagers[0];
        } catch (GeneralSecurityException e) {
            throw new AssertionError(); // The system has no TLS. Just give up.
        }
    }

    @Override
    public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {

    }

    @Override
    public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {

        LogUtil.sslLog("sslCache checkServerTrusted");
        /**
         * 1. 是否能找到xx证书。不能的话抛出异常
         * 2. 判断证书是否过期,若过期的话也抛出异常
         */
        X509Certificate certificate = findXXXCertificate(chain);
        if (certificate == null) {
            throw new xx异常(); //改成你们项目的异常对象即可
        } else {
            //证书过期时间戳
            long expireTime = certificate.getNotAfter().getTime();
            //当前时间戳
            long currentTimeMillis = System.currentTimeMillis();
            //证书过期,抛出异常. 提前15天抛出异常,通知服务器提前更新证书。
            if (currentTimeMillis >= (expireTime - FIFTEEN_DAY)) {
                throw new xx异常(); //改成你们项目的异常对象即可
            }
        }

        //这里就是真正的通过系统默认的TrustManager 来验证证书合法性了。上面仅仅只是验证相关参数是否符合要求而已,比如是否包含公司名,是否过期等等。
        mSystemDefaultTrustManager.checkServerTrusted(chain, authType);

    }

    @Override
    public X509Certificate[] getAcceptedIssuers() {
        return mSystemDefaultTrustManager.getAcceptedIssuers();
    }

    /**
     * 找到证书
     * 把XXX 换成你们公司名或其他名
     * @param chain
     * @return
     */
    private X509Certificate findXXXCertificate(X509Certificate[] chain) {
        X509Certificate certificate = null;
        if (chain != null) {
            for (X509Certificate x509Certificate : chain) {
                if (x509Certificate != null && checkSubject(x509Certificate.getSubjectDN().getName())) {
                    certificate = x509Certificate;
                    break;
                }
            }
        }

        return certificate;
    }

    /**
     * 校验证书的主体标识是否合法
     * CN合法来源:*.xx.com.cn
     *
     * @param subjectDn 其一般格式为:CN=*.xx.com.cn,OU=xx,O=xx Mobile Communication Co., Ltd.,L=xx,ST=xx,C=CN
     * @return
     */
    private boolean checkSubject(String subjectDn) {
        
        if (TextUtils.isEmpty(subjectDn)) {
            return false;
        }
        String[] signs = subjectDn.split(",");
        if (signs == null || signs.length <= 0) {
            return false;
        }
        HashMap<String, String> map = new HashMap<>();
        for (String str : signs) {
            String[] keyValueArray = str.split("=");
            int keyValueLen = 2;
            if (keyValueArray.length != keyValueLen) {
                continue;
            }
            map.put(keyValueArray[0], keyValueArray[1]);
        }
        if (!TextUtils.equals(map.get("CN"), VALID_SUBJECT_1)) {
            return false;
        }
        return true;
    }
}

自定义了TrustManager 之后,就可以真正的初始化 SSLContext 了,具体如下:

SSLContext sslContext = SSLContext.getInstance("TLSv1.2");

//因为客户端指定验证服务器证书,所以不需要设置KeyManager 和 随机参数了,这里直接设null即可。
sslContext.init(null, new TrustManager[] {new CustomX509TrustManager()}, null);

注意:若服务器证书是完整的,客户端就不需要设置KeyManager。否则的话是需要指定的,当前行业内方案一般都是校验服务器证书,比较安全且更换证书比较方便。若是放在客户端不仅不安全更换证书还无比的繁琐,还要考虑兼容性等等。

4.创建长链接

初始化好之后,就可以考虑创建Socket长链接了

public void start() throws IOException {
    
    SSLContext sslContext = SSLContext.getInstance("TLSv1.2");

    //因为客户端指定验证服务器证书,所以不需要设置KeyManager 和 随机参数了,这里直接设null即可。
    sslContext.init(null, new TrustManager[] {new CustomX509TrustManager()}, null);

    //通过Socket建立连接。注意这里暂时没有直接使用SSLSocket,是因为考虑到非加密方案可以使用这台代码兼容
    SocketAddress sockaddr = new InetSocketAddress(mHost, mPort);
    Socket mSocket = mFactory.createSocket();
    mSocket.connect(sockaddr, mConTimeout * 1000);
    
    int soTimeout = mSocket.getSoTimeout();
    if (soTimeout == 0) {
        // RTC 765: Set a timeout to avoid the SSL handshake being blocked
        // indefinitely
        mSocket.setSoTimeout(this.mHandshakeTimeoutSecs * 1000);
    }

    /**
     * 在handshake回调中添加业务处理,目前仅打印日志而已
     */
    ((SSLSocket) mSocket).addHandshakeCompletedListener(SSLManager.createHandShakeCompleteListener());

    ((SSLSocket) mSocket).startHandshake(); //启动ssl握手
    // reset timeout to default value
    mSocket.setSoTimeout(soTimeout);

}

我们重点关注 ((SSLSocket) mSocket).startHandshake(); 代码即可,该行代码就是触发SSL握手协议的请求。

ok,至此我们就完成了 SSLSocket 加密通道的长链接建立了,之后的所有数据经过该传输通道都安全可靠的了。那我们的任务就此完成了吗?当然不是,在上线过程中我们遇到了流量危机,接下来我们主要讲SessionTickets 缓存机制。

SessionTickets

流量异常

我们的安全通道上线后发现流量消耗出现较大异常,赶紧回去找答案,就是SSL握手环节导致的流量消耗,因为我们是移动端长链接,因为移动端网络抖动等原因,导致长链接重连次数比较多,每次重连都会经过SSL 握手过程,其中一个环节就是证书下发过程,我们证书有 几十KB 大小,所以每次建立长链接在下发证书这块就会多消耗 几十KB 的流量,那重试次数多了,消耗的流量也就多了。

注意:不仅仅是消耗流量,同时也会影响性能,毕竟多传输了几十KB的数据,建连的速度也就变慢了。

那么有没有办法不用每次都下发证书呢?当然有,并且服务端和客户端都可以实现。

服务器方案

其实在SSL 握手时就会创建一个Session会话,服务端只要保存该Session 记录,下次客户端重新连接的时候就可以复用该Session会话,而不需要再次下发证书了。但是该方案优劣势比较明显。

优点:服务器可以随时控制Session的过期时间,比较安全。

缺点:针对每个客户端都要做一份Session缓存,如果量级过大的话会导致存储消耗过大,存储成本开销不小。

所以暂时没有考虑该方案。

客户端方案

客户端支持 SessionTickets 方案,原理是一样的:在发起请求时,开启SessionTickets 功能,并设置SessionTickets 过期时间即可。你以为这就完了?no,经过仔细查找,发现低版本Android SDK 根本就没有提供对应的开启功能API。最后通过阅读源码找到了一点蛛丝马迹,顺藤摸瓜最后找到了解决方案。

启动SessionTikets 功能代码如下: 具体就不展开讲解了,代码注释说明比较详细了。

/**
*
* 打开SessionTickets 功能
*/
public static boolean enableSessionTickets(SSLSocket socket) {

    if (socket == null) {
        return false;
    }

    boolean result = true;
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        //Android10 之后通过SSLSockets 设置
        SSLSockets.setUseSessionTickets((SSLSocket) socket, true);

    } else {
        //Android10 以前,通过 OpenSSLSocketImpl 的setUseSessionTickets 方法设置。 参考SSLCertificateSocketFactory的实现。
        //其中OpenSSLSocketImpl继承于 SSLSocket。 源码链接:https://android.googlesource.com/platform/external/conscrypt/+/f087968/src/main/java/org/conscrypt/OpenSSLSocketImpl.java?autodive=0%2F%2F%2F%2F
        //而最终的SSL实现类是继承于OpenSSLSocketImpl的。参考官网链接:https://developer.android.com/about/versions/oreo/android-8.1?hl=vi
        //最终继承关系为:ConscryptFileDescriptorSocket -> OpenSSLSocketImpl -> SSLSocket
        Class c = socket.getClass();
        try {
            Method m = c.getMethod("setUseSessionTickets", boolean.class);
            m.invoke(socket, true);
        } catch (Exception e) {
            e.printStackTrace();
            result = false;
        }
    }

    return result;
}

光打开 SessionTickets 功能还不行,还得支持SessionCache 的缓存才行, 参考源码我们也给出了设置缓存的代码:


/**
 * 通过反射设置自定义SSLSessionCache。
 *
 * @param cache
 * @param sslContext
 * @return 成功:true , 失败:false
 */
public static boolean setSSLSessionCache(SSLSessionCache cache, SSLContext sslContext) {

    if (cache == null || sslContext == null) {
        return false;
    }

    boolean result;
    try {
        //通过反射获取 SSLSessionCache.mSessionCache 字段。 该字段是在new SSLSessionCache(PushCoreController.getInstance().getContext()); 的时候生成的。
        Field clientSessionCacheField = cache.getClass().getDeclaredField("mSessionCache");
        clientSessionCacheField.setAccessible(true);
        Object clientSessionCacheObj = clientSessionCacheField.get(cache);

        //调用 SSLClientSessionCache 的 setPersistentCache 方法,设置自定义SSLSessionCache.
        Class<?> clientCacheClass = sslContext.getClientSessionContext().getClass();
        Method method = clientCacheClass.getMethod("setPersistentCache", Class.forName("com.android.org.conscrypt.SSLClientSessionCache"));
        method.invoke(sslContext.getClientSessionContext(), clientSessionCacheObj);
        result = true;
    } catch (Exception e) {
        e.printStackTrace();
        result = false;
    }

    return result;
}

打开SessionTickets 并设置了 对应的缓存后,我们再来优化下我们的建连代码:



public void start() throws IOException {
    
    SSLContext sslContext = SSLContext.getInstance("TLSv1.2");

    //因为客户端指定验证服务器证书,所以不需要设置KeyManager 和 随机参数了,这里直接设null即可。
    sslContext.init(null, new TrustManager[] {new CustomX509TrustManager()}, null);

    //通过Socket建立连接。注意这里暂时没有直接使用SSLSocket,是因为考虑到非加密方案可以使用这台代码兼容
    SocketAddress sockaddr = new InetSocketAddress(mHost, mPort);
    Socket mSocket = mFactory.createSocket();
    mSocket.connect(sockaddr, mConTimeout * 1000);


    //启动 SessionTickets 功能
    enableSessionTickets(sslSocket);
    
    
    //设置缓存cache
    //通过指定context,在app指定目录下创建默认的SSLSessionCache,供APP所有业务场景复用。
    SSLSessionCache cache = new SSLSessionCache(mContext);
    //设置自定义SSLSessionCache
    if (!setSSLSessionCache(cache, createSSLContext())) {
       //如果不支持的话,大家业务上可以做对应的异常处理
    }

    setSSLSessionCache(cache, sslContext)
    
    int soTimeout = mSocket.getSoTimeout();
    if (soTimeout == 0) {
        // RTC 765: Set a timeout to avoid the SSL handshake being blocked
        // indefinitely
        mSocket.setSoTimeout(this.mHandshakeTimeoutSecs * 1000);
    }

    /**
     * 在handshake回调中添加业务处理,目前仅打印日志而已
     */
    ((SSLSocket) mSocket).addHandshakeCompletedListener(SSLManager.createHandShakeCompleteListener());

    ((SSLSocket) mSocket).startHandshake(); //启动ssl握手
    // reset timeout to default value
    mSocket.setSoTimeout(soTimeout);

}

至此,我们完成了自定义 Socket 长连接通道的加密,并优化实现了证书的缓存机制。

总结

下面总结下我们本篇文章的核心内容

  1. 简要介绍了SSL。
  2. 客户端建立 SSL 长链接通道原理 与 API级别的介绍
  3. Session 会话缓存机制介绍和API实现,避免频繁下发证书导致流量和性能问题。

大家有什么疑问欢迎评论区留言讨论。

参考文档

SSL,TLS的区别和介绍

一篇文章让你彻底弄懂SSL/TLS协议

sslcontext 介绍

SSLContext API介绍

java中 SSL认证和keystore使用