HTTPS是一种通过计算机网络进行安全通信的传输协议,经由HTTP进行通信,利用SSL/TLS建立全信道,加密数据包。HTTPS使用的主要目的是提供对网站服务器的身份认证,同时保护交换数据的隐私与完整性。简单来说,HTTPS就是“安全版”的HTTP, HTTPS = HTTP + SSL。HTTPS相当于在应用层和TCP层之间加入了一个SSL/TLS,SSL层对从应用层收到的数据进行加密。TLS/SSL中使用了RSA非对称加密,对称加密以及HASH算法。
PS:TLS是传输层安全协议,前身是SSL协议,它是一种新的协议,建立在SSL 3.0协议规范之上,是SSL 3.0的后续版本,可以理解为SSL 3.1,它是写入了 RFC。TLS是基于 X.509 认证,他假定所有的数字证书都是由一个层次化的数字证书认证机构 CA发出的。
描述:如果现在 A 要与远端的 B 建立安全的连接进行通信:
为了解决上述问题,引入了一个第三方,也就是上面所说的CA(Certificate Authority):
CA 用自己的私钥签发数字证书,数字证书中包含A的公钥。然后 B 可以用 CA 的根证书中的公钥来解密 CA 签发的证书,从而拿到A的公钥。那么又引入了一个问题,如何保证 CA 的公钥是合法的呢?答案就是现代主流的浏览器会内置 CA 的证书。
中间证书:
现在大多数CA不直接签署服务器证书,而是签署中间CA,然后用中间CA来签署服务器证书。这样根证书可以离线存储来确保安全,即使中间证书出了问题,可以用根证书重新签署中间证书。另一个原因是为了支持一些很古老的浏览器,有些根证书本身,也会被另外一个很古老的根证书签名,这样根据浏览器的版本,可能会看到三层或者是四层的证书链结构,如果能看到四层的证书链结构,则说明浏览器的版本很老,只能通过最早的根证书来识别
校验过程:
实际上,在 HTTPS 握手开始后,服务器会把整个证书链发送到客户端,给客户端做校验。校验的过程是要找到这样一条证书链,链中每个相邻节点,上级的公钥可以校验通过下级的证书,链的根节点是设备信任的锚点或者根节点可以被锚点校验。那么锚点对于浏览器而言就是内置的根证书啦(注:根节点并不一定是根证书)。校验通过后,视情况校验客户端,以及确定加密套件和用非对称密钥来交换对称密钥。从而建立了一条安全的信道。
数字证书除了由第三方私钥签名(比如由ca机构签名),其实也可以用自己的私钥签名。
用自己的私钥给自己的公钥签名的证书称为自签名证书。自签名证书可以自我验证,即可以拿证书的公钥解证书的签名。由第三方私钥签名的证书就不是自签名证书,验证时需要由存放第三方公钥的证书验证。
Android对于Https的支持在系统层面上已经帮我们封装的很好了,系统会内置合法ca机构的根证书,只要服务器证书是由这些机构或者是其中间机构签发的那么系统会自动做安全校验,我们不需要做任何事情,直接请求https就可以。
但是如果服务器证书是自签名的,就需要我们对请求逻辑做一定的改造,不然你发起的所有请求都会失败,因为系统在做ssl证书校验时会认为这是一个非法请求直接阻断掉。
接下来探讨下一个案例:A公司旗下运营着某某app产品,周末这个app某个模块功能突然就拿不到数据,正在度假的小黄啊马上被传召回来公司加班解决问题,小黄开始打测试包调试哦,发现数据很正常啊,因为这块代码不是小黄负责,忙活了很久,这时网络中心的人突然说了句:周五我们换了服务器端证书(证书过期了),这个会不会有影响啊?小黄:“。。。”,小黄毕竟有点实力的,过一会就查出了问题所在:原来客户端本地内置了份证书,生产环境对其进行了校验,而测试环境则是忽略了证书的校验。这时领导认为这种方案有点问题,希望客户端不依赖证书(网络框架使用的是OkHttp3以上)。
从这个案例可以看出A公司本来的做法应该是这样的:
测试环境用的是自签名证书,该环境下信任所有证书设置:对于非CA机构颁发的证书和自签名证书,可以忽略证书校验
/**
* 创建信任所有证书的套接字工厂
*
* @return
*/
@SuppressLint("TrulyRandom")
public static SSLSocketFactory createTrustAllSSLSocketFactory() {
SSLSocketFactory sSLSocketFactory = null;
try {
SSLContext sc = SSLContext.getInstance("TLS");
sc.init(null, new TrustManager[]{
new TrustAllManager()}, new SecureRandom());
sSLSocketFactory = sc.getSocketFactory();
} catch (Exception ignored) {
}
return sSLSocketFactory;
}
/**
* 信任所有的证书
*/
public static class TrustAllManager implements X509TrustManager {
@SuppressLint("TrustAllX509TrustManager")
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
}
@SuppressLint("TrustAllX509TrustManager")
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
// //检查所有证书
// try {
// TrustManagerFactory factory = TrustManagerFactory.getInstance("X509");
// factory.init((KeyStore) null);
// for (TrustManager trustManager : factory.getTrustManagers()) {
// ((X509TrustManager) trustManager).checkServerTrusted(chain, authType);
// }
// //获取网络中的证书信息
// X509Certificate certificate = chain[0];
// // 证书拥有者
// String subject = certificate.getSubjectDN().getName();
// // 证书颁发者
// String issuer = certificate.getIssuerDN().getName();
//
// LogManager.getLogger().e("HHHTEST:-->", "证书拥有者:" + subject);
// LogManager.getLogger().e("HHHTEST:-->", "证书颁发者:" + issuer);
//
// } catch (Exception e) {
// e.printStackTrace();
Log.e("HHHTEST", "Exception:" + e.getMessage());
// LogManager.getLogger().e("HHHTEST:-->", "Exception:" + e.getMessage());
// }
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
}
如果不这样设置,直接访问该https域名是会报错的:
javax.net.ssl.SSLHandshakeException: java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.
正式环境的证书设置:客户端内置服务器中间证书(根证书),请求时对其进行身份校验(单向验证)
/**
* 检验内置证书
*
* @param context
* @return
*/
public static SSLSocketFactory getSSlFactory(Context context) {
try {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
//把证书打包在asset文件夹中 BuildConfig.AUTH_CERT:证书名称
InputStream caInput = new BufferedInputStream(context.getAssets().open(BuildConfig.AUTH_CERT));
Certificate ca;
try {
ca = cf.generateCertificate(caInput);
LogManager.getLogger().d("Longer", "ca=" + ((X509Certificate) ca).getSubjectDN());
LogManager.getLogger().d("Longer", "key=" + ((X509Certificate) ca).getPublicKey());
} finally {
caInput.close();
}
// Create a KeyStore containing our trusted CAs
String keyStoreType = KeyStore.getDefaultType();
KeyStore keyStore = KeyStore.getInstance(keyStoreType);
keyStore.load(null, null);
keyStore.setCertificateEntry("ca", ca);
// Create a TrustManager that trusts the CAs in our KeyStore
String tmfAlgorithm = TrustManagerFactory.getDefaultAlgorithm();
TrustManagerFactory tmf = TrustManagerFactory.getInstance(tmfAlgorithm);
tmf.init(keyStore);
// Create an SSLContext that uses our TrustManager
SSLContext s = SSLContext.getInstance("TLSv1", "AndroidOpenSSL");
s.init(null, tmf.getTrustManagers(), null);
return s.getSocketFactory();
} catch (CertificateException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
} catch (KeyStoreException e) {
e.printStackTrace();
} catch (KeyManagementException e) {
e.printStackTrace();
} catch (NoSuchProviderException e) {
e.printStackTrace();
}
return null;
}
这种做法安全系数还是比较高的:在客户端内置服务器的证书,我们在校验服务端证书的时候只比对和App内置的证书是否完全相同,如果不同则断开连接。那么此时再遭遇中间人攻击劫持我们的请求时由于黑客服务器没有相应的证书,此时HTTPS请求校验不通过,则无法与黑客的服务器建立起连接。但是这需要考虑证书的有效期,升级等,如果证书过期了,app也需要更新,并且还存在旧版本客户端无法访问的问题,这就有点头大了。
PS:其实还有个不错的的信任方式是把签发我们服务器的证书的根证书导出打包到 APK 中,然后用上述的方式做信任处理。仔细思考一下,这未尝不是一种好的方式。只要日后换证书还用这家 CA 签发,既不用担心失效,安全性又有了一定的提高。因为比起信任100多个根证书,只信任一个风险会小很多。正如最开始所说,信任锚点未必需要根证书,因此同样上面的代码也可以用于自签名证书的信任。
Android 内置的 SSL 的实现是引入了Conscrypt 项目,而 HTTP(S)层则是使用的OkHttp。而 SSL 层只负责校验证书的真假,对于所有基于SSL 的应用层协议,需要自己来校验证书实体的身份。
/**
* 信任域名
*/
public static class TrustXHostnameVerifier implements HostnameVerifier {
@Override
public boolean verify(String hostname, SSLSession session) {
//BASE_URL 域名
boolean verify = HttpsURLConnection.getDefaultHostnameVerifier().verify(BASE_URL, session);
LogManager.getLogger().e("HHHTEST", "hostname:" + hostname);
LogManager.getLogger().e("HHHTEST", "verify:" + verify);
return verify;
}
}
verify方法中对比了请求的IP和服务器的IP是否一致,一致则返回true表示校验通过,否则返回false,检验不通过,断开连接。对于网上有些处理是直接返回true,即不对请求的服务器IP做校验,我们不推荐这样使用。而且现在谷歌应用商店已经对此种做法做了限制,禁止在verify方法中直接返回true的App上线。
测试环境
mOkHttpClient = new OkHttpClient().newBuilder()
.connectTimeout(10, TimeUnit.SECONDS)//设置超时时间
.readTimeout(10, TimeUnit.SECONDS)//设置读取超时时间
.writeTimeout(10, TimeUnit.SECONDS)//设置写入超时时间
.sslSocketFactory(SSLUtils.createTrustAllSSLSocketFactory())
.hostnameVerifier(new SSLUtils.TrustXHostnameVerifier())
.build();
正式环境
mOkHttpClient = new OkHttpClient().newBuilder()
.connectTimeout(10, TimeUnit.SECONDS)//设置超时时间
.readTimeout(10, TimeUnit.SECONDS)//设置读取超时时间
.writeTimeout(10, TimeUnit.SECONDS)//设置写入超时时间
.sslSocketFactory(SSLUtils.getSSlFactory(context))
.hostnameVerifier(new SSLUtils.TrustXHostnameVerifier())
.build();
对于上述两种方法都是有点问题的,我们需要考虑安全性和证书过期,升级的问题。正式的https域名的申请是需要CA机构认证的,所以我们可以综合这两种方法,不设置SSLSocketFactory,因为OkHttp默认是支持Https的,默认的 SSLSocketFactory 校验服务器的证书时,会信任Android设备内置的100多个根证书(CA证书),对于此种情况,虽然可以正常访问到服务器,但是仍然存在安全隐患。假如黑客自家搭建了一个服务器并申请到了CA证书,由于我们客户端没有内置服务器证书,默认信任所有CA证书(客户端可以访问所有持有由CA机构颁发的证书的服务器),那么黑客仍然可以发起中间人攻击劫持我们的请求到黑客的服务器,实际上就成了我们的客户端和黑客的服务器建立起了连接。