fred-ye / summary

my blog
43 stars 9 forks source link

[Android][Security] SSL Pining #22

Open fred-ye opened 10 years ago

fred-ye commented 10 years ago

起因

上周大家都安静的等待着release,美国客户那边突然要求我们在项目的这个milestone里把certificate pinning加进去。按道理来说,在release前几天不应该再加这种effort比较大的task。后来知道,是因为那边公司的上层要视察,强烈要求。看来领导视察这种活动是普遍存在啊,美国也不例外。没办法,客户就是上帝,我们就硬着头皮做task,连续加班熬夜搞了两晚上。在此,小总结一下。

在我们上网的时候,如果涉及到支付相关的操作,比如说在网上用支付宝买东西,你会发现地址栏中的URL是以https开头的;用Google的很多产品也会发现URL是以https开头;访问本网页,同样,URL也是采用https开头。这是因为这些服务提供商为了保证用户数据的安全性采用的一种措施。HTTPS可以理解是加密了的HTTP请求,在传输过程中会对数据进行加密。现在当你访问一些正规的网站时,若URL是以HTTPS开头的,很多浏览器的地址栏会有一个绿色的锁的标识,表示当前的访问是安全的。如下图: image

SSL 与 HTTPS

SSL(Secure Sockets Layer 安全套接层)是为网络通信提供安全及数据完整性的一种安全协议。需要注意一点是,它是在传输层对网络连接进行加密。SSL协议的优势在于它是与应用层协议独立无关的。高层的应用层协议(例如:HTTP、FTP、Telnet等等)能透明的建立于SSL协议之上。SSL协议在应用层协议通信之前就已经完成加密算法、通信密钥的协商以及服务器认证工作。在此之后应用层协议所传送的数据都会被加密,从而保证通信的私密性。SSL协议是基于非对称加密算法的。关于非对称加密,稍后会讲到。 HTTPS(Hypertext Transfer Protocol Secure)安全超文本传输协议是一个属于应用层的协议,HTTPS是以安全为目标的HTTP通道,简单讲是HTTP的安全版。即HTTP下加入SSL层,HTTPS的安全基础是SSL。

非对称加密 了解非对称加密之前先了解一下对称加密。简单来讲,对称加密的算法的加密和解密都是采用同一个密钥。如数据A,通过使用密钥B,加密成为密文C。任何人,只要获得了密钥B,就能够对截获的密文C解密,还原出源数据A。 而非对称加密算法需要两个密钥:公开密钥(publickey)和私有密钥(privatekey)。公开密钥与私有密钥是一对,如果用公开密钥对数据进行加密,只有用对应的私有密钥才能解密;如果用私有密钥对数据进行加密,那么只有用对应的公开密钥才能解密。因为加密和解密使用的是两个不同的密钥,所以这种算法叫作非对称加密算法。

HTTPS通信流程

1,客户端向服务端发出请求,服务端将公钥(以及服务端证书)响应给客户端; 2,客户端接收到服务器端端公钥与证书,验证证书是否在信任域内,不信任则结束通信,信任则使用服务端传过来的公钥生成一个"预备主密码",返回给服务端。 3,服务端接收客户端传过来的"预备主密码"密文,使用私钥解密。非对称加密的安全性也就在于此了,第三方无法获取到"预备主密码"的明文,因为除了服务端,其他任何人是没有私钥的。 4,双方使用"预备主密码"生成用于会话的"主密码"。确认后,结束本次握手,停止使用非对称加密。 5,双方使用"主密码"对称加密传输数据,直到本次会话结束。

需要注意的

1 HTTPS是基于SSL的,在SSL通信过程中,客户端是基于数字证书判断服务器是否可信,并采用证书的公钥与服务器进行加密通信。 2 在客户端是维护着一个证书的列表。以浏览器为例,浏览器中都保存着一个证书列表。我们都可以通过查看浏览器的设置选项来查看浏览器中内嵌的证书。客户端在验证证书的时候会将服务器端的证书和本地的证书进行匹配,如果匹配成功,那么就认为当前访问的证站是可信的,便继续访问获取站点的数据。

如果想更多的了解SSL/HTTPS, 可以看以下几个链接:

Android中,当我们采用HTTPS与服务器进行通信时,我们会向服务器发一个HTTPS请求。一个HTTPS请求可以分为两部分,第一部份是建立连接,拿到证书,进行证书的验证;第二部份则是读取Server端的数据。一段代码,来点直观印象:

public class MySSLSocketFactory extends SSLSocketFactory{
    public static final String TAG = "MySSLSocketFactory";
    SSLContext sslContext = SSLContext.getInstance("TLS");

    public MySSLSocketFactory(KeyStore truststore) throws NoSuchAlgorithmException,
            KeyManagementException, KeyStoreException,
            UnrecoverableKeyException {
        super(truststore);
        TrustManager tm = new X509TrustManager() {
            @Override
            public X509Certificate[] getAcceptedIssuers() {
                // TODO Auto-generated method stub
                return null;
            }

            @Override
            public void checkServerTrusted(X509Certificate[] chain, String authType)
                    throws CertificateException {
                //此处返回的证书在一个证书链中,该证书链包含了多个证书。若自己公司向第三方证书机构申  
               //请了证书,那么chain[0]是自己公司的证书,数组后面的几个元素是第三方证书机构的证书。在实际项目中我们比对的是chain[0].
                for (int i = 0 ; i < chain.length; i ++) {
                    Log.i("Certificate" + (i + 1), chain[0].getPublicKey().toString());
                }
            }

            @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 HttpClient getHttpClient () {
       try {
           KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
           trustStore.load(null, null);
           HttpParams params = new BasicHttpParams();
           HttpProtocolParams.setVersion(params, HttpVersion.HTTP_1_1);
           HttpProtocolParams.setContentCharset(params, HTTP.UTF_8);

           MySSLSocketFactory sf = new MySSLSocketFactory(trustStore);
           //信任所有的主机,这是一种很不安全的做法,此时我们为了连接上主机并拿到证书,先这么做。
           sf.setHostnameVerifier(MySSLSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER);

           SchemeRegistry registry = new SchemeRegistry();
           registry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80));
           registry.register(new Scheme("https", sf, 443));
           ClientConnectionManager ccm = new ThreadSafeClientConnManager(params, registry);
           return new DefaultHttpClient(ccm, params);
       } catch (Exception e) {
           e.printStackTrace();
           return new DefaultHttpClient();
       }
   }
}
public class MainActivity extends Activity {
    private Button btnPin;
    private String TAG = "MainActivity";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        Log.i("SSL", "password");
        btnPin = (Button)findViewById(R.id.btn_pin);
        btnPin.setOnClickListener(clickListener);
}

    private View.OnClickListener clickListener = new View.OnClickListener() {

        @Override
        public void onClick(View view) {
            new Thread() {
                @Override
                public void run() {
                    HttpClient client = MySSLSocketFactory.getHttpClient(); 
                    // create
                    client.getParams().setParameter(CoreConnectionPNames.CONNECTION_TIMEOUT, 5000);
                    client.getParams().setParameter(CoreConnectionPNames.SO_TIMEOUT, 5000);
                    HttpGet get = new HttpGet("https://github.com/fred-ye/summary/issues/22");
                    try {
                        Log.i(TAG, "execute--->");
                        client.execute(get);
                    } catch (UnknownHostException e) {
                        Log.i(TAG, "UnknownHostException--->" + e.getMessage());
                    } catch (ConnectException e) {
                        Log.i(TAG, "ConnectException--->" + e.getMessage());
                    } catch (Exception e) {
                        Log.i(TAG, e.getMessage());
                    }
                }
            }.start();
        }
    };
}

右侧内容便是server端返回的公钥。

证书绑定Certificate Pinning

Certificate Pinning也就是SSL Pinning。其原理是在本地(客户端)保存有一份证书,当访问Server的时候,将本地证书和从Server端获取到的证书进行比对,如果比对成功,继续完成请求数据的操作。采用这种方式,服务器端可以自己实现一个证书,同时在Server端也保留一份。但有一个缺点便是,当Server端的证书一旦更新,客户端(app)便需要更新证书了。之前的app可能就不能用了。

从Server端获取证书的操作我们已经实现了,接着看如何进行证书的校验。需要重写我们自定义的MySSLSocketFactory类, 其实主要是我们的重写X509TrustManager的实现方式。改动如下:


import android.util.Log;

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.HttpParams;
import org.apache.http.params.HttpProtocolParams;
import org.apache.http.protocol.HTTP;

import java.io.IOException;
import java.math.BigInteger;
import java.net.Socket;
import java.security.KeyManagementException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.UnrecoverableKeyException;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.security.interfaces.RSAPublicKey;

import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;

public class MySSLSocketFactory extends SSLSocketFactory {
    private static final String TAG = "SSL";
    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 checkClientTrusted(X509Certificate[] chain,
                                           String authType) throws java.security.cert.CertificateException {
            }

            @Override
            public void checkServerTrusted(java.security.cert.X509Certificate[] chain,
                                           String authType) throws java.security.cert.CertificateException {
                if (null == chain || 0 == chain.length) {
                    throw new CertificateException("Certificate chain is invalid.");
                } else if (null == authType || 0 == authType.length()) {
                    throw new CertificateException("Authentication type is invalid.");
                } else {
                    for (X509Certificate cert : chain) {
                        cert.checkValidity();
                        String publicKeyStr = getPublicKeyStr(cert.getPublicKey());
                        //TDDO 开始进行公钥的比对,将拿到的公钥和本地hard code的一个公钥进行比对。通常我们会将公钥进行md5或sha摘要运算后存在本地,
                        // 如果比对失败就抛一个CertificateException出去,此处就省略比对逻辑。
                        Log.i(TAG, "SSL public key:" +  publicKeyStr);

                    }
                }
                throw new CustomAbortHttpRequestException("Check certificate finished, abort http request!");
            }
        };
        sslContext.init(null, new TrustManager[]{tm}, null);
    }

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

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

    /**
     * get new HttpClient
     *
     * @return
     */
    public static HttpClient getNewHttpClient() {
        DefaultHttpClient defaultHttpClient = null;
        try {
            KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
            trustStore.load(null, null);

            MySSLSocketFactory sf = new MySSLSocketFactory(trustStore);
            sf.setHostnameVerifier(SSLSocketFactory.STRICT_HOSTNAME_VERIFIER);

            HttpParams params = new BasicHttpParams();
            HttpProtocolParams.setVersion(params, HttpVersion.HTTP_1_1);
            HttpProtocolParams.setContentCharset(params, HTTP.UTF_8);

            SchemeRegistry registry = new SchemeRegistry();
            registry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80));
            registry.register(new Scheme("https", sf, 443));

            ClientConnectionManager clientConnectionManager = new ThreadSafeClientConnManager(params, registry);

            defaultHttpClient = new DefaultHttpClient(clientConnectionManager, params);
        } catch (Exception e) {
            Log.e(TAG, "init check pin HttpClient exception: " + e.getMessage());
        }

        return defaultHttpClient;
    }
    //获取public key 的值。
    public static String getPublicKeyStr(PublicKey publicKey) {
        RSAPublicKey rsaPublicKeyKey = (RSAPublicKey) publicKey;
        BigInteger module = new BigInteger(rsaPublicKeyKey.getModulus().toString());
        return module.toString(16);
    }
}
   throw new RuntimeException("Check certificate finished, abort http request!");

这个是为了当我们一校验完证书之后,得到了校验结果,立马终止当前的请求,提高响应速度。因为我们的目的只在证书校验。

如上面所说的那样,这种做法存在着一个问题,就是必须要在本地hard code一个public key 用来与实时拿到的证书的public key进行比对。如果server端的证书换了,这种做法就悲剧了。这种做法其实在乌云上面有一个实现方式。采用这种方式一个问题就是如何更安全的存储这个值。在这个链接中也提到了,就是将证书存到keystore中。

在项目中,我们的app会和三家公司的server进行通信,如果有一台server的证书换了,我们就要升级app了。后来我们换了另外一种实现方式,避免server换证书导致的app不能用这个问题。

大概思路如下:

  1. 我们app本地在内存中hard code一个值,这个值是由一个RSA算法产生的public key.
  2. app每次在启动的时候会从我们指定的一个url上下载一段文本,这段文本中包含了三个server的public key。当然这三个public key都是经过一定处理后拼接成一个字符串。对这个字符串采用RSA加密,使用私钥加密,这个私钥是和步聚1中的公钥对应的。将加密后的数据存到这个文本中的。拿到这段文本后,我们会用步骤1里面的public key对其进行解密,这样我们便拿到了三个server的public key。
  3. 每次当我们访问api是,我们可以从checkServerTrusted这个方法中拿到public key, 然后与2中得到的三个server的public key进行比对,如果一致,则认为SSL Pinning成功,否则便是失败,终止请求。

    RSA 算法

RSA算法用得还是蛮多的,这里简单记录一下,在Android中如何用RSA算法进行加密和解密。

   public void testRSA() throws Exception {
        KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); 
        kpg.initialize(1024);
        KeyPair kp = kpg.genKeyPair(); //生成密钥对
        PublicKey publicKey = kp.getPublic(); //获得公钥
        PrivateKey privateKey = kp.getPrivate(); //获得私钥
        String publicKeyStr = Base64.encodeToString(publicKey.getEncoded(), Base64.DEFAULT);
        Log.i(TAG, "publicKeyStr:" + publicKeyStr);
        String privateKeyStr = Base64.encodeToString(privateKey.getEncoded(), Base64.DEFAULT);
        Log.i(TAG, "privateKeyStr:" + privateKeyStr);
        String encryptedData = encrypteWithPrivateKey("HelloWorld", privateKeyStr);
        decryptWithPublicKey(encryptedData, publicKeyStr);
    } 
    public String encrypteWithPrivateKey(String plaintext, String privateKeystr) throws GeneralSecurityException {
        byte [] keyBytes = Base64.decode(privateKeystr, Base64.DEFAULT);
        PKCS8EncodedKeySpec x509KeySpec = new PKCS8EncodedKeySpec(keyBytes);
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        Key privateKey = keyFactory.generatePrivate(x509KeySpec);
        Cipher cipher = Cipher.getInstance("RSA");
        cipher.init(Cipher.ENCRYPT_MODE, privateKey);
        byte [] data = cipher.doFinal(plaintext.getBytes());
        String encryptedData = Base64.encodeToString(data, Base64.DEFAULT);
        Log.i(TAG, "encrypted string:" + encryptedData) ;
        return encryptedData;
    }
    public void decryptWithPublicKey(String data, String publicKeyStr)
            throws GeneralSecurityException {
        byte [] keyBytes = Base64.decode(publicKeyStr, Base64.DEFAULT);
        X509EncodedKeySpec x509KeySpec = new X509EncodedKeySpec(keyBytes);
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        Key publicKey = keyFactory.generatePublic(x509KeySpec);
        String publicKeyStr2= Base64.encodeToString(publicKey.getEncoded(), Base64.DEFAULT);
        Log.i(TAG, "publicKeyStr2:" + publicKeyStr2);
        Cipher cipher = Cipher.getInstance("RSA");
        //在实际开发过程中有碰到过BadPaddingException, 这是因为不同平台(java和android)RSA算法的加密模式和填充方式有点差异导致。采用下面一行代码就可以了。
       // Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
        cipher.init(Cipher.DECRYPT_MODE, publicKey);
        byte [] dataBytes = Base64.decode(data, Base64.DEFAULT);
        Log.i(TAG, "decrypted string:" + new String(cipher.doFinal(dataBytes))) ;
    }

我们的应用场景

我们当前的项目是一款移动支付项目。会调用银行提供的接口,数据提交到银行的操作是调银行那边提供的一个jar包里的方法,应该是采用Socket通信,将数据发到银行后台的Server,为了安全,我们在调银行的接口之前先检测一下银行提供的一个URL的证书。确认安全后再调用提交数据的方法,将数据提交上去。

假设现在用户要购买一个商品,通过我们的app已经输入了卡号等信息,走到了最后一步,只要点确认这个按钮便会调api,将请求支付的信息发给银行,银行就要开始扣钱了。我们的程序设计是这样子的:

  1. 向这个api对应的server发请求,拿证书,做SSL Pinning。 若SSL Pinning成功了,接着做第二步。 如果失败了,直接告诉用户"证书错误"。
  2. 将交易信息提交给这个api,若提交成功,本次交易成功;若提交失败,本次交易失败。 也就是说,我们的每次做一个操作,会发两次api请求,第一次是做证书验证,第二次才是将业务数据发送出去。两次http请求构造httpclient的SSLSocketFactory是不一样的,第一个里面需要检测证书,第二个里面就没有必要做证书检测了。其实如果说没有调用第三方的jar里面的东西,直接采用http提交数据,我们只需要发一次请求就可以,就是在发这个请求的同时进行证书验证。
jhaoheng commented 6 years ago

您好,拜读了您的文章 想请问,如果用双向认证,是否可以更好的解决

一个 app <-> 三个伺服器凭证的问题

三个伺服器都用不同的凭证,app 也有自己的凭证放到伺服器进行验证 这样只要 app 凭证不更换的情况下,三个伺服器如何变更都可以被验证 除非最糟糕的情况 app 被 hack, 但这种情况下 ssl pinning 也是一样会有风险