HTTPS原理
HTTPS(Hyper Text Transfer Protocol Secure),是一種基於SSL/TLS的HTTP,所有的HTTP數據都是在SSL/TLS協議封裝之上進行傳輸的。HTTPS協議是在HTTP協議的基礎上,添加了SSL/TLS握手以及數據加密傳輸,也屬於應用層協議。所以,研究HTTPS協議原理,最終就是研究SSL/TLS協議。
不使用SSL/TLS的HTTP通信,就是不加密的通信,所有的信息明文傳播,帶來了三大風險:
竊聽風險:第三方可以獲知通信內容。
篡改風險:第三方可以修改通知內容。
冒充風險:第三方可以冒充他人身份參與通信。
SSL/TLS協議是爲了解決這三大風險而設計的,希望達到:
所有信息都是加密傳輸,第三方無法竊聽。
具有校驗機制,一旦被篡改,通信雙方都會立刻發現。
配備身份證書,防止身份被冒充。
基本的運行過程:
SSL/TLS協議的基本思路是採用公鑰加密法,也就是說,客戶端先向服務器端索要公鑰,然後用公鑰加密信息,服務器收到密文後,用自己的私鑰解密。但是這裏需要了解兩個問題的解決方案。
如何保證公鑰不被篡改?
解決方法:將公鑰放在數字證書中。只要證書是可信的,公鑰就是可信的。
公鑰加密計算量太大,如何減少耗用的時間?
解決方法:每一次對話(session),客戶端和服務器端都生成一個“對話密鑰”(session key),用它來加密信息。由於“對話密鑰”是對稱加密,所以運算速度非常快,而服務器公鑰只用於加密“對話密鑰”本身,這樣就減少了加密運算的消耗時間。
因此,SSL/TLS協議的基本過程是這樣的:
客戶端向服務器端索要並驗證公鑰。
雙方協商生成“對話密鑰”。
雙方採用“對話密鑰”進行加密通信。
上面過程的前兩布,又稱爲“握手階段”。
握手階段的詳細過程
“握手階段”涉及四次通信,需要注意的是,“握手階段”的所有通信都是明文的。
客戶端發出請求(ClientHello)
首先,客戶端(通常是瀏覽器)先向服務器發出加密通信的請求,這被叫做ClientHello請求。在這一步中,客戶端主要向服務器提供以下信息:
支持的協議版本,比如TLS 1.0版
一個客戶端生成的隨機數,稍後用於生成“對話密鑰”。
支持的加密方法,比如RSA公鑰加密。
支持的壓縮方法。
這裏需要注意的是,客戶端發送的信息之中不包括服務器的域名。也就是說,理論上服務器只能包含一個網站,否則會分不清應用向客戶端提供哪一個網站的數字證書。這就是爲什麼通常一臺服務器只能有一張數字證書的原因。
服務器迴應(ServerHello)
服務器收到客戶端請求後,向客戶端發出迴應,這叫做ServerHello。服務器的迴應包含以下內容:
確認使用的加密通信協議版本,比如TLS 1.0版本。如果瀏覽器與服務器支持的版本不一致,服務器關閉加密通信。
一個服務器生成的隨機數,稍後用於生成“對話密鑰”。
確認使用的加密方法,比如RSA公鑰加密。
服務器證書。
除了上面這些信息,如果服務器需要確認客戶端的身份,就會再包含一項請求,要求客戶端提供“客戶端證書”。比如,金融機構往往只允許認證客戶連入自己的網絡,就會向正式客戶提供USB密鑰,裏面就包含了一張客戶端證書。
客戶端迴應
客戶端收到服務器迴應以後,首先驗證服務器證書。如果證書不是可信機構頒發,或者證書中的域名與實際域名不一致,或者證書已經過期,就會向訪問者顯示一個警告,由其選擇是否還要繼續通信。
如果證書沒有問題,客戶端就會從證書中取出服務器的公鑰。然後,向服務器發送下面三項消息。
一個隨機數。該隨機數用服務器公鑰加密,防止被竊聽。
編碼改變通知,表示隨後的信息都將用雙方商定的加密方法和密鑰發送。
客戶端握手結束通知,表示客戶端的握手階段已經結束。這一項通常也是前面發送的所有內容的hash值,用來供服務器校驗。
上面第一項隨機數,是整個握手階段出現的第三個隨機數,又稱“pre-master key”。有了它以後,客戶端和服務器就同時有了三個隨機數,接着雙方就用事先商定的加密方法,各自生成本次會話所用的同一把“會話密鑰”。
服務器的最後迴應
服務器收到客戶端的第三個隨機數pre-master key之後,計算生成本次會話所用的“會話密鑰”。然後,向客戶端最後發送下面信息。
編碼改變通知,表示隨後的信息都將用雙方商定的加密方法和密鑰發送。
服務器握手結束通知,表示服務器的握手階段已經結束。這一項同時也是前面發生的所有內容的hash值,用來供客戶端校驗。
握手結束
至此,整個握手階段全部結束。接下來,客戶端與服務器進入加密通信,就完全是使用普通的HTTP協議,只不過用“會話密鑰”加密內容。
服務器基於Nginx搭建HTTPS虛擬站點
之前一篇文章詳細介紹了在服務器端如何生成SSL證書,並基於Nginx搭建HTTPS服務器,鏈接:Nginx搭建HTTPS服務器。
Android實現HTTPS通信
之前使用了HttpClient來實現HTTPS通信,而且代碼中有大量無關代碼,自己回顧看起來都特別混亂.所以,這裏只列出HttpUrlConnection實現HTTPS通信的關鍵代碼。
CA認證的數字證書網站
我們以百度的https網址(https://m.baidu.com/)爲例,示例源碼如下:
public void startHttpsConnection() {
HttpsURLConnection httpsURLConnection = null;
BufferedReader reader = null;
try {
URL url = new URL("https://m.baidu.com/");
httpsURLConnection = (HttpsURLConnection) url.openConnection();
httpsURLConnection.setConnectTimeout(5000);
httpsURLConnection.setDoInput(true);
httpsURLConnection.setUseCaches(false);
httpsURLConnection.connect();
reader = new BufferedReader(new InputStreamReader(httpsURLConnection.getInputStream()));
StringBuilder sBuilder = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sBuilder.append(line);
}
Log.e("TAG", "Wiki content=" + sBuilder.toString());
} catch (MalformedURLException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
if (httpsURLConnection != null) {
httpsURLConnection.disconnect();
}
if (reader != null) {
try {
reader.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
由於百度是有CA授權的數字證書,所以這裏我們就是簡單的使用HttpsUrlConnection對其進行訪問,就實現了HTTPS通信。
自簽名的數字證書網站
由於CA認證是需要收費的,所以有些網站爲了節約成本,採用自簽名的數字證書,偉大的12306目前依然是這麼幹的。如果我們用上述代碼訪問自簽名的網站會有什麼問題呢?
截取一段crash信息如下:
04-13 14:47:30.539 28776-28816/com.genius.wzy W/System.err: javax.net.ssl.SSLHandshakeException: java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.
04-13 14:47:30.539 28776-28816/com.genius.wzy W/System.err: at com.android.org.conscrypt.OpenSSLSocketImpl.startHandshake(OpenSSLSocketImpl.java:409)
04-13 14:47:30.539 28776-28816/com.genius.wzy W/System.err: at com.android.okhttp.Connection.upgradeToTls(Connection.java:153)
04-13 14:47:30.539 28776-28816/com.genius.wzy W/System.err: at com.android.okhttp.Connection.connect(Connection.java:114)
04-13 14:47:30.539 28776-28816/com.genius.wzy W/System.err: at com.android.okhttp.internal.http.HttpEngine.connect(HttpEngine.java:298)
04-13 14:47:30.539 28776-28816/com.genius.wzy W/System.err: at com.android.okhttp.internal.http.HttpEngine.sendSocketRequest(HttpEngine.java:259)
04-13 14:47:30.539 28776-28816/com.genius.wzy W/System.err: at com.android.okhttp.internal.http.HttpEngine.sendRequest(HttpEngine.java:206)
04-13 14:47:30.539 28776-28816/com.genius.wzy W/System.err: at com.android.okhttp.internal.http.HttpURLConnectionImpl.execute(HttpURLConnectionImpl.java:345)
04-13 14:47:30.539 28776-28816/com.genius.wzy W/System.err: at com.android.okhttp.internal.http.HttpURLConnectionImpl.connect(HttpURLConnectionImpl.java:89)
04-13 14:47:30.539 28776-28816/com.genius.wzy W/System.err: at com.android.okhttp.internal.http.HttpsURLConnectionImpl.connect(HttpsURLConnectionImpl.java:161)
04-13 14:47:30.539 28776-28816/com.genius.wzy W/System.err: at com.genius.wzy.MainActivity.startHttpsConnection(MainActivity.java:58)
04-13 14:47:30.539 28776-28816/com.genius.wzy W/System.err: at com.genius.wzy.MainActivity$1.run(MainActivity.java:34)
04-13 14:47:30.539 28776-28816/com.genius.wzy W/System.err: at java.lang.Thread.run(Thread.java:841)
可以看到,訪問自簽名證書的網站,Android直接會throw SSLHandshakeException,原因就是12306的數字證書不被Android系統的信任。想解決這個問題,有如下幾種方法。
讓HttpsURLConnection信任所有的CA證書
這是網上資源最多也是最不靠譜的解決方案。具體實現方法如下。
Step1. 實現X509TrustManager接口,在接口實現中跳過客戶端和服務器端認證。
public class TrustAllCertsManager implements X509TrustManager {
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType)
throws CertificateException {
// Do nothing -> accept any certificates
}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType)
throws CertificateException {
// Do nothing -> accept any certificates
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
}
Step2. 實現HostnameVerifier接口,不進行url和服務器主機名的驗證。
public class VerifyEverythingHostnameVerifier implements HostnameVerifier {
@Override
public boolean verify(String hostname, SSLSession session) {
return true;
}
}
Step3. 基於上面實現的TrustAllCertsManager修改HttpsURLConnection類的默認SSL socket factory。
TrustManager[] trustManager = new TrustManager[] {new TrustEverythingTrustManager()};
SSLContext sslContext = null;
try {
sslContext = SSLContext.getInstance("SSL");
sslContext.init(null, trustManager, new java.security.SecureRandom());
} catch (NoSuchAlgorithmException e) {
// do nothing
}catch (KeyManagementException e) {
// do nothing
}
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.getSocketFactory());
Setp4. 實例化HttpsUrlConnection,並設置HostnameVerifier爲上面實現的VerifyEverythingHostnameVerifier。
httpsURLConnection = (HttpsURLConnection) url.openConnection();
httpsURLConnection.setHostnameVerifier(new VerifyEverythingHostnameVerifier());
上述四個步驟,就可以讓你無障礙的訪問自簽名的HTTPS網站了,例如12306。但是,這種方式雖然簡單,但是會導致嚴重的安全問題,例如臭名昭著的中間人攻擊。
中間人攻擊
雖然上述方案使用了HTTPS,客戶端和服務器端的通信內容得到了加密,嗅探程序無法得到傳輸的內容,但是無法抵擋“中間人攻擊”。例如,在內網配置一個DNS,把目標服務器域名解析到本地的一個地址,然後在這個地址上使用一箇中間服務器作爲代理,它使用一個假的證書與客戶端通訊,然後再由這個代理服務器作爲客戶端連接到實際的服務器,用真的證書與服務器通訊。這樣所有的通訊內容都會經過這個代理,而客戶端不會感知,這是由於客戶端不校驗服務器公鑰證書導致的。
所以,千萬不要在生產代碼中使用上述方法解決HTTPS無法連接的問題。
讓HttpsURLConnection信任指定的CA證書
爲了防止上面方案可能導致的“中間人攻擊”,我們可以事先下載服務器端公鑰證書,然後將公鑰證書編譯到Android應用中,由應用自己來驗證證書。也就是我們來教會HttpsUrlConnection來認識特定的自簽名網站。還是以12306網站爲例。
Step1. 下載12306的服務器公鑰證書
12306提供了公鑰的下載地址:12306根證書下載地址
Step2. 將下載的證書放到應用的assets目錄下.
app->src->main->assets->srca.cer
(ps:使用Android Studio的同學需要特別注意默認asserts目錄的位置)。
Setp3. 構造特定的TrustManager[]數組.
private TrustManager[] createTrustManager() {
BufferedInputStream cerInputStream = null;
try {
// 獲取客戶端存放的服務器公鑰證書
cerInputStream = new BufferedInputStream(getAssets().open("srca.cer"));
// 根據公鑰證書生成Certificate對象
CertificateFactory cf = CertificateFactory.getInstance("X.509");
Certificate ca = cf.generateCertificate(cerInputStream);
Log.e("TAG", "ca=" + ((X509Certificate) ca).getSubjectDN());
// 生成包含當前CA證書的keystore
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
keyStore.load(null, null);
keyStore.setCertificateEntry("ca", ca);
// 使用包含指定CA證書的keystore生成TrustManager[]數組
String tmfAlgorithm = TrustManagerFactory.getDefaultAlgorithm();
TrustManagerFactory tmf = TrustManagerFactory.getInstance(tmfAlgorithm);
tmf.init(keyStore);
return tmf.getTrustManagers();
} catch (CertificateException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} catch (KeyStoreException e) {
e.printStackTrace();
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
} finally {
if (cerInputStream != null) {
try {
cerInputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return null;
}
Step4. 初始化SSLContext.
SSLContext sc = SSLContext.getInstance("SSL");
TrustManager[] trustManagers = createTrustManager();
if (trustManagers == null) {
Log.e("TAG", "tmf create failed!");
return;
}
sc.init(null, trustManagers, new SecureRandom());
URL url = new URL("https://kyfw.12306.cn/otn/login/init");
HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());
參考文獻