在Android应用中使用自定义证书的HTTPS连接(上)

前言

由于移动设备使用的网络环境各种各样,而且常常接入不安全的公共WIFI——如果你对公共WIFI环境的安全性没有警惕性的话,就难怪你开发出不安全的程序,把你的用户置于危险境地——这话一点都不夸张。

而要想在不安全的网络环境下安全地使用网络,最好的办法就是通过VPN连接到安全网络环境中去。但这并不总是能够保证的。所以需要应用开发者在开发的时候尽量减少用户的安全风险。

通过HTTPS连接网络是一种常用的方法。但是在实际使用中存在几个困难:

* 使用商业证书的成本
* 使用自定义证书不被系统承认
* 忽略证书验证则可能被“中间人攻击”

本文将针对这些问题讨论技术解决方案。

因为最近又开始试用Android Studio,所以这里的Demo是用Android Studio 0.4.2写的。完整的Demo代码可以在bitbucket获得:https://bitbucket.org/raptorz/democert

基本的HTTP连接方式

首先来看基本的HTTP连接方式实现,程序的项目框架是直接用向导生成后略作修改。主要就是增加一个异步网络调用的任务,任务内容大致为:

HttpUriRequest request = new HttpGet(url);
HttpClient client = DemoHttp.getClient();
try {
    HttpResponse httpResponse = client.execute(request);
    int responseCode = httpResponse.getStatusLine().getStatusCode();
    String message = httpResponse.getStatusLine().getReasonPhrase();
    HttpEntity entity = httpResponse.getEntity();
    if (responseCode == 200 && entity != null)
    {
        return parseString(entity);
    }
}
finally {
    client.getConnectionManager().shutdown();
}
return "";

上面这个函数功能就是创建一个HttpClient去GET url的内容,如果HTTP返回值为200(即无错误),则返回响应内容。

重点就在DemoHttp.getClient()里,对于基本的HTTP连接,以下是实现部分代码:

public static HttpClient getClient() {
    BasicHttpParams params = new BasicHttpParams();
    HttpProtocolParams.setVersion(params, HttpVersion.HTTP_1_1);
    HttpProtocolParams.setContentCharset(params, HTTP.DEFAULT_CONTENT_CHARSET);
    HttpProtocolParams.setUseExpectContinue(params, true);
SchemeRegistry schReg = new SchemeRegistry();
schReg.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80));

ClientConnectionManager connMgr = new ThreadSafeClientConnManager(params, schReg);
return new DefaultHttpClient(connMgr, params);

}

实际的实现代码当然比上面这两段多得多了,Java就是这么麻烦,一点小事都要写一大堆代码,为节约篇幅就不全部列出了,参见bitbucket上的完整代码吧。

顺便说一句,写网络通讯应用别忘记在Manifest.xml里加上相应的权限,否则会出一些很奇怪的错误。

HTTP连接的主要问题在于在传输过程中的内容都是明文,只要在同一网段内使用嗅探程序即可获得网内其它设备与服务器之间的通讯内容,完全没有安全性。

使用系统承认的商业证书的HTTPS连接方式

在上面的例子中,如果尝试用https连接的话,会报错称不支持https: Scheme 'https' not registered。最简单的解决办法就是参照HTTP的方式,加入对HTTPS的支持:

schReg.register(new Scheme("https", SSLSocketFactory.getSocketFactory(), 443));

关键代码就这么一句。

现在就可以像打开HTTP链接一样打开有效的HTTPS连接了,比如: https://www.google.com.hk 。但可耻的 12306 的HTTPS却不行,因为它使用了不被系统承认的自定义证书:No peer certificate 。

这个方案使用了HTTPS连接,传输内容经过加密,嗅探程序已经无法获得通讯内容。而服务器的证书经过合法签名,被系统所承认,正常通讯也没有问题。

但是需要花钱买证书。

使用自定义证书并忽略验证的HTTPS连接方式

如果不想花钱,那么就只能用OPENSSL自己做一个证书,但问题在于,这个证书得不到系统的承认,后果同 12306 。为了解决这个问题,一个办法是跳过系统校验。

要跳过系统校验,就不能再使用系统标准的SSLSocketFactory了,需要自定义一个。然后为了在这个自定义SSLSocketFactory里跳过校验,还需要自定义一个TrustManager,在其中忽略所有校验,即TrustAll。

以下就是SSLTrustAllSocketFactory和SSLTrustAllManager的实现:

public class SSLTrustAllSocketFactory extends SSLSocketFactory {
private static final String TAG = "SSLTrustAllSocketFactory";
private SSLContext mCtx;

public class SSLTrustAllManager implements X509TrustManager {

    @Override
    public void checkClientTrusted(X509Certificate[] arg0, String arg1)
            throws CertificateException {
    }

    @Override
    public void checkServerTrusted(X509Certificate[] arg0, String arg1)
            throws CertificateException {
    }

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

}

public SSLTrustAllSocketFactory(KeyStore truststore)
        throws Throwable {
    super(truststore);
    try {
        mCtx = SSLContext.getInstance("TLS");
        mCtx.init(null, new TrustManager[] { new SSLTrustAllManager() },
                null);
        setHostnameVerifier(SSLSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER);
    } catch (Exception ex) {
    }
}

@Override
public Socket createSocket(Socket socket, String host,
                           int port, boolean autoClose)
        throws IOException, UnknownHostException {
    return mCtx.getSocketFactory().createSocket(socket, host, port, autoClose);
}

@Override
public Socket createSocket() throws IOException {
    return mCtx.getSocketFactory().createSocket();
}

public static SSLSocketFactory getSocketFactory() {
    try {
        KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
        trustStore.load(null, null);
        SSLSocketFactory factory = new SSLTrustAllSocketFactory(trustStore);
        return factory;
    } catch (Throwable e) {
        Log.d(TAG, e.getMessage());
        e.printStackTrace();
    }
    return null;
}

}

最后在注册scheme时使用这个Factory:

schReg.register(new Scheme("https", SSLTrustAllSocketFactory.getSocketFactory(), 443));

这样就可以成功打开 12306 的内容了。

不过这个方案虽然用了HTTPS,通讯的内容也经过了加密,嗅探程序也无法知道内容。但是通过更麻烦一些的“中间人攻击”,还是可以窃取通讯内容的:

在 网内配置一个DNS,把目标服务器域名解析到本地的一个地址,然后在这个地址上用一个中间服务器作代理,它使用一个假的证书与客户端通讯,然后再由这个代 理作为客户端连到实际的服务器,用真的证书与服务器通讯。这样所有的通讯内容就会经过这个代理。而因为客户端不校验证书,所以它用来加密的证书实际上是代 理提供的假证书,那么代理就可以完全知道通讯内容。这个代理就是所谓的“中间人”。

但是不幸的是,网上搜到的大部分关于自定义证书的HTTPS连接实现都是用这种忽略验证的方式实现的。

所以我们需要更安全的方式。详见下篇。

推送到[go4pro.org]