11、Android原生网络请求证书绑定:HttpURLConnection证书绑定、Socket证书绑定、自定义TrustManager
聊到Android原生网络请求的证书绑定,很多朋友第一反应就是「用OkHttp的CertificatePinner不就行了?」。嗯,确实方便。但现实项目中,总有那么些老代码、第三方SDK或者特殊场景,逼得你不得不用原生的HttpURLConnection甚至直接操作Socket。这时候,证书绑定就得我们自己手写了。
我个人习惯把这类绑定分为三层:应用层(HttpURLConnection)、传输层(Socket)和底层校验逻辑(TrustManager)。今天咱们就一层层剥开来看。
11.1 为什么原生请求需要证书绑定?
说白了,就是防止中间人攻击。你想想看,如果用户的设备上被安装了恶意CA证书(比如某些抓包工具、企业监控软件),那么攻击者就能解密你的HTTPS流量。证书绑定的核心思路是:我只认我自己的服务器证书,其他的一概不信。
我在项目中遇到过这样一个坑:某个金融类App,网络层用的是HttpURLConnection,没有做任何绑定。结果用户反馈说在某些WiFi环境下登录异常。一查,原来是那个WiFi热点做了SSL劫持。从那以后,我对所有涉及敏感数据的请求都强制加了绑定。
11.2 HttpURLConnection证书绑定
HttpURLConnection本身不直接提供证书绑定的API。但我们可以通过自定义HostnameVerifier和SSLSocketFactory来实现。思路是这样的:
- 获取服务器证书的指纹(SHA-256或SHA-1)
- 在连接建立时,校验服务器返回的证书指纹是否匹配
- 不匹配则直接抛出异常,拒绝连接
来看代码:
public class PinningSSLSocketFactory extends SSLSocketFactory {
private SSLSocketFactory defaultFactory;
private String expectedFingerprint;
public PinningSSLSocketFactory(String expectedFingerprint) {
this.defaultFactory = (SSLSocketFactory) SSLSocketFactory.getDefault();
this.expectedFingerprint = expectedFingerprint;
}
@Override
public String[] getDefaultCipherSuites() {
return defaultFactory.getDefaultCipherSuites();
}
@Override
public String[] getSupportedCipherSuites() {
return defaultFactory.getSupportedCipherSuites();
}
@Override
public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException {
Socket socket = defaultFactory.createSocket(s, host, port, autoClose);
verifyPinning(socket);
return socket;
}
@Override
public Socket createSocket(String host, int port) throws IOException {
Socket socket = defaultFactory.createSocket(host, port);
verifyPinning(socket);
return socket;
}
// 其他createSocket重载方法类似...
private void verifyPinning(Socket socket) {
if (socket instanceof SSLSocket) {
SSLSocket sslSocket = (SSLSocket) socket;
sslSocket.startHandshake();
Certificate[] certs = sslSocket.getSession().getPeerCertificates();
// 取叶子证书(第一个)
byte[] encoded = certs[0].getEncoded();
String fingerprint = sha256(encoded);
if (!expectedFingerprint.equals(fingerprint)) {
throw new SSLException("证书指纹不匹配!预期: " + expectedFingerprint + ",实际: " + fingerprint);
}
}
}
private String sha256(byte[] data) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(data);
return bytesToHex(digest);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
private String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}
然后在使用HttpURLConnection时,这样配置:
URL url = new URL("https://your-api.com/endpoint");
HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
conn.setSSLSocketFactory(new PinningSSLSocketFactory("你的SHA256指纹"));
conn.setHostnameVerifier(new HostnameVerifier() {
@Override
public boolean verify(String hostname, SSLSession session) {
// 这里可以额外校验hostname,或者直接返回true(因为证书指纹已经校验了)
return true;
}
});
openssl x509 -in your-cert.pem -noout -fingerprint -sha256
11.3 Socket证书绑定
有些场景下,你可能会直接操作Socket进行通信,比如自定义协议、或者使用某些老旧库。这时候,证书绑定就得在Socket层面做。
我记得有一次,一个游戏SDK内部用的是原生Socket + TLS,没有暴露任何配置接口。为了加上绑定,我不得不hook了它的SSLSocket创建过程。嗯,虽然有点暴力,但确实有效。
直接操作Socket的绑定逻辑,其实和HttpURLConnection类似,只不过我们需要手动触发握手并校验:
public class PinningSocketFactory {
private String expectedFingerprint;
public PinningSocketFactory(String expectedFingerprint) {
this.expectedFingerprint = expectedFingerprint;
}
public Socket createPinnedSocket(String host, int port) throws IOException {
SSLSocketFactory factory = (SSLSocketFactory) SSLSocketFactory.getDefault();
SSLSocket socket = (SSLSocket) factory.createSocket(host, port);
// 手动触发握手
socket.startHandshake();
// 获取证书链
Certificate[] certs = socket.getSession().getPeerCertificates();
byte[] encoded = certs[0].getEncoded();
String fingerprint = sha256(encoded);
if (!expectedFingerprint.equals(fingerprint)) {
socket.close();
throw new SSLException("Socket证书绑定失败!指纹不匹配");
}
return socket;
}
// sha256和bytesToHex方法同上
}
使用方式:
PinningSocketFactory pinningFactory = new PinningSocketFactory("你的SHA256指纹");
Socket socket = pinningFactory.createPinnedSocket("your-api.com", 443);
// 然后通过socket的输入输出流进行通信
11.4 自定义TrustManager
TrustManager是Android SSL校验的底层核心。自定义TrustManager,说白了就是接管「这个证书我信不信」的决策权。这是最灵活、也是最危险的方式。
我个人的建议是:能用指纹绑定的,就别用TrustManager。因为TrustManager一旦写错,可能连正常的HTTPS连接都断了,或者更糟——所有连接都放行。
来看一个只信任特定证书的TrustManager实现:
public class PinningTrustManager implements X509TrustManager {
private String expectedFingerprint;
public PinningTrustManager(String expectedFingerprint) {
this.expectedFingerprint = expectedFingerprint;
}
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
// 客户端证书校验,一般用不到
}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
if (chain == null || chain.length == 0) {
throw new CertificateException("证书链为空");
}
// 取叶子证书
X509Certificate cert = chain[0];
try {
byte[] encoded = cert.getEncoded();
String fingerprint = sha256(encoded);
if (!expectedFingerprint.equals(fingerprint)) {
throw new CertificateException("证书指纹不匹配!预期: " + expectedFingerprint + ",实际: " + fingerprint);
}
} catch (Exception e) {
throw new CertificateException("证书校验失败", e);
}
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
// sha256和bytesToHex方法同上
}
然后把它设置到SSLContext中:
TrustManager[] trustManagers = new TrustManager[]{
new PinningTrustManager("你的SHA256指纹")
};
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustManagers, new SecureRandom());
HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
conn.setSSLSocketFactory(sslContext.getSocketFactory());
11.5 三种方式的对比与选择
| 方式 | 灵活度 | 实现复杂度 | 推荐场景 |
|---|---|---|---|
| HttpURLConnection绑定 | 中 | 低 | 简单API调用,已有HttpURLConnection代码 |
| Socket绑定 | 高 | 中 | 自定义协议、老旧SDK、非HTTP通信 |
| 自定义TrustManager | 最高 | 高 | 需要深度定制校验逻辑、多证书场景 |
你想想看,如果你的项目里全是OkHttp,那直接用CertificatePinner多省事。但现实是,很多老项目、或者集成了某些第三方库,它们内部用的是原生网络请求。这时候,上面这三种方式就是你的救命稻草。
11.6 知识体系总览
下面这张图,帮你理清今天讲的所有内容:
从这张图可以看出来,无论你用哪种方式,最终落地的核心都是「SHA-256证书指纹比对」。上层再怎么封装,底层逻辑不变。
好了,关于Android原生网络请求的证书绑定,今天就聊到这里。三种方式各有优劣,关键是根据你的项目场景来选择。记住一点:绑定不是目的,安全才是。