前言
最近有一個跟HTTPS相關的問題需要解決,因此花時間學習了一下Android平台HTTPS的使用,同時也看了一些HTTPS的原理,這裡分享一下學習心得。
HTTPS原理
HTTPS(Hyper Text Transfer Protocol Secure),是一種基於SSL/TLS的HTTP,所有的HTTP資料都是在SSL/TLS協議封裝之上進行傳輸的。HTTPS協議是在HTTP協議的基礎上,添加了SSL/TLS握手以及資料加密傳輸,也屬於應用程式層協議。所以,研究HTTPS協議原理,最終就是研究SSL/TLS協議。
SSL/TLS協議作用
不使用SSL/TLS的HTTP通訊,就是不加密的通訊,所有的資訊明文傳播,帶來了三大風險:
1. 竊聽風險:第三方可以獲知通訊內容。
2. 篡改風險:第三方可以修改通知內容。
3. 冒充風險:第三方可以冒充他人身份參與通訊。
SSL/TLS協議是為瞭解決這三大風險而設計的,希望達到:
1. 所有資訊都是加密傳輸,第三方無法竊聽。
2. 具有校正機制,一旦被篡改,通訊雙方都會立刻發現。
3. 配備身份認證,防止身份被冒充。
基本的運行過程
SSL/TLS協議的基本思路是採用公開金鑰加密法,也就是說,用戶端先向伺服器端索要公開金鑰,然後用公開金鑰加密資訊,伺服器收到密文後,用自己的私密金鑰解密。但是這裡需要瞭解兩個問題的解決方案。
1. 如何保證公開金鑰不被篡改?
解決方案:將公開金鑰放在數位憑證中。只要認證是可信的,公開金鑰就是可信的。
2. 公開金鑰加密計算量太大,如何減少耗用的時間?
解決方案:每一次對話(session),用戶端和伺服器端都產生一個“對話密鑰”(session key),用它來加密資訊。由於“對話密鑰”是對稱式加密,所以運算速度非常快,而伺服器公開金鑰只用於加密“對話密鑰”本身,這樣就減少了加密運算的消耗時間。
因此,SSL/TLS協議的基本過程是這樣的:
1. 用戶端向伺服器端索要並驗證公開金鑰。
2. 雙方協商產生“對話密鑰”。
3. 雙方採用“對話密鑰”進行加密通訊。
上面過程的前兩布,又稱為“握手階段”。
握手階段的詳細過程
握手階段”涉及四次通訊,需要注意的是,“握手階段”的所有通訊都是明文的。
用戶端發出請求(ClientHello)
首先,用戶端(通常是瀏覽器)先向伺服器發出加密通訊的請求,這被叫做ClientHello請求。在這一步中,用戶端主要向伺服器提供以下資訊:
1. 支援的協議版本,比如TLS 1.0版
2. 一個用戶端產生的隨機數,稍後用於產生“對話密鑰”。
3. 支援的加密方法,比如RSA公開金鑰加密。
4. 支援的壓縮方法。
這裡需要注意的是,用戶端發送的資訊之中不包括伺服器的網域名稱。也就是說,理論上伺服器只能包含一個網站,否則會分不清應用向用戶端提供哪一個網站的數位憑證。這就是為什麼通常一台伺服器只能有一張數位憑證的原因。
伺服器回應(ServerHello)
伺服器收到用戶端請求後,向用戶端發出回應,這叫做ServerHello。伺服器的回應包含以下內容:
1. 確認使用的加密通訊協定版本,比如TLS 1.0版本。如果瀏覽器與伺服器支援的版本不一致,伺服器關閉加密通訊。
2. 一個伺服器產生的隨機數,稍後用於產生“對話密鑰”。
3. 確認使用的加密方法,比如RSA公開金鑰加密。
4. 伺服器憑證。
除了上面這些資訊,如果伺服器需要確認用戶端的身份,就會再包含一項請求,要求用戶端提供“用戶端認證”。比如,金融機構往往只允許認證客戶連入自己的網路,就會向正式客戶提供USB密鑰,裡面就包含了一張用戶端認證。
用戶端回應
用戶端收到伺服器回應以後,首先驗證伺服器憑證。如果認證不是可信機構頒發,或者認證中的網域名稱與實際網域名稱不一致,或者認證已經到期,就會向訪問者顯示一個警告,由其選擇是否還要繼續通訊。
如果認證沒有問題,用戶端就會從認證中取出伺服器的公開金鑰。然後,向伺服器發送下面三項訊息。
1. 一個隨機數。該隨機數用伺服器公開金鑰加密,防止被竊聽。
2. 編碼改變通知,表示隨後的資訊都將用雙方商定的加密方法和密鑰發送。
3. 用戶端握手結束通知,表示用戶端的握手階段已經結束。這一項通常也是前面發送的所有內容的hash值,用來供伺服器校正。
上面第一項隨機數,是整個握手階段出現的第三個隨機數,又稱“pre-master key”。有了它以後,用戶端和伺服器就同時有了三個隨機數,接著雙方就用事先商定的加密方法,各自產生本次會話所用的同一把“工作階段金鑰”。
伺服器的最後回應
伺服器收到用戶端的第三個隨機數pre-master key之後,計算產生本次會話所用的“工作階段金鑰”。然後,向用戶端最後發送下面資訊。
1. 編碼改變通知,表示隨後的資訊都將用雙方商定的加密方法和密鑰發送。
2. 伺服器握手結束通知,表示伺服器的握手階段已經結束。這一項同時也是前面發生的所有內容的hash值,用來供用戶端校正。
握手結束
至此,整個握手階段全部結束。接下來,用戶端與伺服器進入加密通訊,就完全是使用普通的HTTP協議,只不過用“工作階段金鑰”加密內容。
伺服器基於Nginx搭建HTTPS虛擬網站
之前一篇文章詳細介紹了在伺服器端如何產生SSL認證,並基於Nginx搭建HTTPS伺服器,連結:Nginx搭建HTTPS伺服器
Android實現HTTPS通訊
由於各種原因吧,這裡使用HttpClicent類講解一下Android如何建立HTTPS串連。代碼demo如下。
MainActivity.java
package com.example.photocrop; import java.io.BufferedReader; import java.io.InputStreamReader; import org.apache.http.HttpResponse; import org.apache.http.HttpStatus; import org.apache.http.StatusLine; import org.apache.http.client.HttpClient; import org.apache.http.client.methods.HttpPost; import org.apache.http.client.methods.HttpUriRequest; import android.app.Activity; import android.os.AsyncTask; import android.os.Bundle; import android.os.AsyncTask.Status; import android.text.TextUtils; import android.util.Log; import android.view.View; import android.widget.Button; import android.widget.TextView; public class MainActivity extends Activity { private Button httpsButton; private TextView conTextView; private CreateHttpsConnTask httpsTask; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); httpsButton = (Button) findViewById(R.id.create_https_button); httpsButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { runHttpsConnection(); } }); conTextView = (TextView) findViewById(R.id.content_textview); conTextView.setText("初始為空白"); } private void runHttpsConnection() { if (httpsTask == null || httpsTask.getStatus() == Status.FINISHED) { httpsTask = new CreateHttpsConnTask(); httpsTask.execute(); } } private class CreateHttpsConnTask extends AsyncTask<Void, Void, Void> { private static final String HTTPS_EXAMPLE_URL = "自訂"; private StringBuffer sBuffer = new StringBuffer(); @Override protected Void doInBackground(Void... params) { HttpUriRequest request = new HttpPost(HTTPS_EXAMPLE_URL); HttpClient httpClient = HttpUtils.getHttpsClient(); try { HttpResponse httpResponse = httpClient.execute(request); if (httpResponse != null) { StatusLine statusLine = httpResponse.getStatusLine(); if (statusLine != null && statusLine.getStatusCode() == HttpStatus.SC_OK) { BufferedReader reader = null; try { reader = new BufferedReader(new InputStreamReader( httpResponse.getEntity().getContent(), "UTF-8")); String line = null; while ((line = reader.readLine()) != null) { sBuffer.append(line); } } catch (Exception e) { Log.e("https", e.getMessage()); } finally { if (reader != null) { reader.close(); reader = null; } } } } } catch (Exception e) { Log.e("https", e.getMessage()); } finally { } return null; } @Override protected void onPostExecute(Void result) { if (!TextUtils.isEmpty(sBuffer.toString())) { conTextView.setText(sBuffer.toString()); } } } }
HttpUtils.java
package com.example.photocrop; import org.apache.http.HttpVersion; import org.apache.http.client.HttpClient; import org.apache.http.conn.ClientConnectionManager; import org.apache.http.conn.scheme.PlainSocketFactory; import org.apache.http.conn.scheme.Scheme; import org.apache.http.conn.scheme.SchemeRegistry; import org.apache.http.conn.ssl.SSLSocketFactory; import org.apache.http.impl.client.DefaultHttpClient; import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager; import org.apache.http.params.BasicHttpParams; import org.apache.http.params.HttpProtocolParams; import org.apache.http.protocol.HTTP; import android.content.Context; public class HttpUtils { public static HttpClient getHttpsClient() { 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)); schReg.register(new Scheme("https", SSLSocketFactory.getSocketFactory(), 443)); ClientConnectionManager connMgr = new ThreadSafeClientConnManager(params, schReg); return new DefaultHttpClient(connMgr, params); } public static HttpClient getCustomClient() { 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)); schReg.register(new Scheme("https", MySSLSocketFactory.getSocketFactory(), 443)); ClientConnectionManager connMgr = new ThreadSafeClientConnManager(params, schReg); return new DefaultHttpClient(connMgr, params); } public static HttpClient getSpecialKeyStoreClient(Context context) { 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)); schReg.register(new Scheme("https", CustomerSocketFactory.getSocketFactory(context), 443)); ClientConnectionManager connMgr = new ThreadSafeClientConnManager(params, schReg); return new DefaultHttpClient(connMgr, params); } }
activity_main.xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <Button android:id="@+id/create_https_button" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hello_world" android:textSize="16sp" /> <TextView android:id="@+id/content_textview" android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center" android:textSize="16sp" /> </LinearLayout>
Android使用DefaultHttpClient建立HTTPS串連,關鍵需要加入對HTTPS的支援:
schReg.register(new Scheme("https", SSLSocketFactory.getSocketFactory(), 443));
加入對HTTPS的支援,就可以有效建立HTTPS串連了,例如“https://www.google.com.hk”了,但是訪問自己基於Nginx搭建的HTTPS伺服器卻不行,因為它使用了不被系統承認的自訂認證,會報出如下問題:No peer certificate。
使用自訂認證並忽略驗證的HTTPS串連方式
解決認證不被系統承認的方法,就是跳過系統校正。要跳過系統校正,就不能再使用系統標準的SSL SocketFactory了,需要自訂一個。然後為了在這個自訂SSL SocketFactory裡跳過校正,還需要自訂一個TrustManager,在其中忽略所有校正,即TrustAll。
package com.example.photocrop; import java.io.IOException; import java.net.Socket; import java.net.UnknownHostException; import java.security.KeyManagementException; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.UnrecoverableKeyException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManager; import javax.net.ssl.X509TrustManager; import org.apache.http.conn.ssl.SSLSocketFactory; public class MySSLSocketFactory extends SSLSocketFactory { SSLContext sslContext = SSLContext.getInstance("TLS"); public MySSLSocketFactory(KeyStore truststore) throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException { super(truststore); TrustManager tm = new X509TrustManager() { @Override public X509Certificate[] getAcceptedIssuers() { return null; } @Override public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { } @Override public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { } }; sslContext.init(null, new TrustManager[] { tm }, null); } @Override public Socket createSocket() throws IOException { return sslContext.getSocketFactory().createSocket(); } @Override public Socket createSocket(Socket socket, String host, int port, boolean autoClose) throws IOException, UnknownHostException { return sslContext.getSocketFactory().createSocket(socket, host, port, autoClose); } public static SSLSocketFactory getSocketFactory() { try { KeyStore trustStore = KeyStore.getInstance(KeyStore .getDefaultType()); trustStore.load(null, null); SSLSocketFactory factory = new MySSLSocketFactory(trustStore); return factory; } catch (Exception e) { e.getMessage(); return null; } } }
同時,需要修改DefaultHttpClient的register方法,改為自己構建的sslsocket:
public static HttpClient getCustomClient() { 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)); schReg.register(new Scheme("https", MySSLSocketFactory.getSocketFactory(), 443)); ClientConnectionManager connMgr = new ThreadSafeClientConnManager(params, schReg); return new DefaultHttpClient(connMgr, params); }
這樣就可以成功的訪問自己構建的基於Nginx的HTTPS虛擬網站了。
缺陷:
不過,雖然這個方案使用了HTTPS,用戶端和伺服器端的通訊內容得到了加密,嗅探程式無法得到傳輸的內容,但是無法抵擋“中間人攻擊”。例如,在內網配置一個DNS,把目標伺服器網域名稱解析到本地的一個地址,然後在這個地址上使用一個中間伺服器作為代理,它使用一個假的認證與用戶端通訊,然後再由這個Proxy 伺服器作為用戶端串連到實際的伺服器,用真的認證與伺服器通訊。這樣所有的通訊內容都會經過這個代理,而用戶端不會感知,這是由於用戶端不校正伺服器密鑰憑證導致的。
使用自訂認證建立HTTPS串連
為了防止上面方案可能導致的“中間人攻擊”,我們可以下載伺服器端密鑰憑證,然後將密鑰憑證編譯到Android應用中,由應用自己來驗證認證。
產生KeyStore
要驗證自訂認證,首先要把認證編譯到應用中,這需要使用keytool工具生產KeyStore檔案。這裡的認證就是指目標伺服器的公開金鑰,可以從web伺服器配置的.crt檔案或.pem檔案獲得。同時,你需要配置bouncycastle,我下載的是bcprov-jdk16-145.jar,至於配置大家自行google就好了。
keytool -importcert -v -trustcacerts -alias example -file www.example.com.crt -keystore example.bks -storetype BKS -providerclass org.bouncycastle.jce.provider.BouncyCastleProvider -providerpath /home/wzy/Downloads/java/jdk1.7.0_60/jre/lib/ext/bcprov-jdk16-145.jar -storepass pw123456
運行後將顯示認證內容並提示你是否確認,輸入Y斷行符號即可。
生產KeyStore檔案成功後,將其放在app應用的res/raw目錄下即可。
使用自訂KeyStore實現串連
思路和TrushAll差不多,也是需要一個自訂的SSLSokcetFactory,不過因為還需要驗證認證,因此不需要自訂TrustManager了。
package com.example.photocrop; import java.io.IOException; import java.io.InputStream; import java.security.KeyManagementException; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.UnrecoverableKeyException; import org.apache.http.conn.ssl.SSLSocketFactory; import android.content.Context; public class CustomerSocketFactory extends SSLSocketFactory { private static final String PASSWD = "pw123456"; public CustomerSocketFactory(KeyStore truststore) throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException { super(truststore); } public static SSLSocketFactory getSocketFactory(Context context) { InputStream input = null; try { input = context.getResources().openRawResource(R.raw.example); KeyStore trustStore = KeyStore.getInstance(KeyStore .getDefaultType()); trustStore.load(input, PASSWD.toCharArray()); SSLSocketFactory factory = new CustomerSocketFactory(trustStore); return factory; } catch (Exception e) { e.printStackTrace(); return null; } finally { if (input != null) { try { input.close(); } catch (IOException e) { e.printStackTrace(); } input = null; } } } }
同時,需要修改DefaultHttpClient的register方法,改為自己構建的sslsocket:
public static HttpClient getSpecialKeyStoreClient(Context context) { 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)); schReg.register(new Scheme("https", CustomerSocketFactory.getSocketFactory(context), 443)); ClientConnectionManager connMgr = new ThreadSafeClientConnManager(params, schReg); return new DefaultHttpClient(connMgr, params); }