前言
最近有一個跟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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
|
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
|
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
< 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的支持:
1
|
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。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
|
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
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,把目標服務器域名解析到本地的一個地址,然后在這個地址上使用一個中間服務器作為代理,它使用一個假的證書與客戶端通訊,然后再由這個代理服務器作為客戶端連接到實際的服務器,用真的證書與服務器通訊。這樣所有的通訊內容都會經過這個代理,而客戶端不會感知,這是由于客戶端不校驗服務器公鑰證書導致的。
使用自定義證書建立HTTPS連接
為了防止上面方案可能導致的“中間人攻擊”,我們可以下載服務器端公鑰證書,然后將公鑰證書編譯到Android應用中,由應用自己來驗證證書。
生成KeyStore
要驗證自定義證書,首先要把證書編譯到應用中,這需要使用keytool工具生產KeyStore文件。這里的證書就是指目標服務器的公鑰,可以從web服務器配置的.crt文件或.pem文件獲得。同時,你需要配置bouncycastle,我下載的是bcprov-jdk16-145.jar,至于配置大家自行google就好了。
1
|
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了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
|
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
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); } |