携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第2天,点击查看活动详情
SSL简介
大家都知道https 比 http 更加安全。这里先简单介绍下相关原理。
如上图所示,https主要比http多了一层ssl/tls 协议,该协议会对整个Socket通信进行加密处理。那么它是如何实现加密的呢?
我们首先来看下http协议下普通的tcp建连过程,主要体现在3次握手建连和4次挥手断连过程,具体如下图所示:
我们再来看下https的具体建连过程,如下图所示:
由上图可以看出:https 建连过程中主要新增加了ssl 握手协议,这里我们重点说明2点:
- Certificate: 服务器下发证书给客户端验证。 证书验证通过则可以进行加密通信了。
- 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)
下面具体说明下其参数含义:
- km :KeyManager主要用于密钥管理。负责客户端的密钥的生成和保存等管理工作。
- tm :信任证书管理者。当要验证服务器 或者 客户端证书时,就是通过该tm负责的。
- random : 随机参数,主要用于协助km生成密钥使用。
这么说可能比较抽象,我们结合 SSL 握手过程看就比较清晰了,具体如下图所示:
由上图可以看出:
- 当服务器证书下发给客户端时,主要就是通过 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 长连接通道的加密,并优化实现了证书的缓存机制。
总结
下面总结下我们本篇文章的核心内容
- 简要介绍了SSL。
- 客户端建立 SSL 长链接通道原理 与 API级别的介绍
- Session 会话缓存机制介绍和API实现,避免频繁下发证书导致流量和性能问题。
大家有什么疑问欢迎评论区留言讨论。