iOS和Android使用SSL雙向認證構建安全通訊信道[轉]

原文鏈接:https://yaoguais.github.io/article/app/https-api.html

轉自:https://yaoguais.github.io/article/app/https-api.html

在移動開發中,服務端和客戶端通訊一般都是使用restful接口,這裏就會涉及到安全問題. 比如: 在服務器端返回的數據中有一個字段是控制某個資源是否可見的(像微信的私密照片),而惡意的開發者通過Charles等工具可以直接修改掉這個字段, 就會導致用戶本不該看到的資源被看到.

這裏面有幾個可行的解決方案:

一、使用私密協議,比如發送接收的數據使用二進制私有協議,但是這會大大增加開發成本,調試起來也比較困難. 現在大多使用的方式是客戶端到服務器使用http post get等請求,服務器返回json數據,中間也可以使用gzip壓縮數據.

二、使用雙向認證的ssl連接(單向的ssl是不安全的,數據會被任意篡改),客戶端和服務器在建立連接的時候, 客戶端會驗證服務器的證書,服務器也會要求客戶端發送服務端簽名過的證書,達到雙向認證,從而避免中間人攻擊.

當然這兩個方案都是建立的客戶端不被反編譯的前提下,如果被反編譯成功.惡意開發者可以破解中間的加密算法,或者拿到所有的證書, 從而擊破安全的壁壘.當然今天主要的內容是SSL雙向認證,而不是反編譯.

目錄:

  1. 服務器生成證書
  2. iOS使用AFNetworking實現SSL雙向認證
  3. 瀏覽器加載客戶端證書實現訪問
  4. charles加載客戶端證書實現調試
  5. Android使用okhttp實現SSL雙向認證

服務器生成證書

nginx多使用bks格式的證書,而像java系的(像tomcat,openfire等)多使用jks格式的證書. 目前服務器這邊多使用nginx,所以我們今天重點在生成bks格式的證書.

我這裏寫了一個shell腳本,幫助生成ca證書/服務端證書/客戶端證書.

#!/bin/bash
# from http://blog.csdn.net/linvo/article/details/9173511
# from http://www.cnblogs.com/guogangj/p/4118605.html

dir=/tmp/ssl
workspace=`pwd`

if [ -d $dir ]; then
    printf "${dir} already exists, remove it?  yes/no: "
    read del
    if [ $del = "yes" ]; then
        rm -rf $dir
    else
        echo "user cancel"
    fi
fi

for d in ${dir} "${dir}/root" "${dir}/server" "${dir}/client" "${dir}/certs"
do
    if [ ! -d $d ]; then
        mkdir $d
    fi
done

echo 'hello world!' >> "${dir}/index.php"
echo 01 > "${dir}/serial"

index_file="${dir}/index.txt"
rm -f $index_file
touch $index_file

echo "generate openssl.cnf: "
openssl_cnf="${dir}/openssl.cnf"
touch $openssl_cnf
echo "[ ca ]
default_ca = yaoguais_ca

[ yaoguais_ca ]
certificate = ./ca.crt
private_key = ./ca.key
database = ./index.txt
serial = ./serial
new_certs_dir = ./certs

default_days = 3650
default_md = sha1

policy = yaoguais_ca_policy
x509_extensions = yaoguais_ca_extensions

[ yaoguais_ca_policy ]
commonName = supplied
stateOrProvinceName = optional
countryName = optional
emailAddress = optional
organizationName = optional
organizationalUnitName = optional

[ yaoguais_ca_extensions ]
basicConstraints = CA:false

[ req ]
default_bits = 2048
default_keyfile = ./ca.key
default_md = sha1
prompt = yes
distinguished_name = root_ca_distinguished_name
x509_extensions = root_ca_extensions

[ root_ca_distinguished_name ]
countryName_default = CN

[ root_ca_extensions ]
basicConstraints = CA:true
keyUsage = cRLSign, keyCertSign

[ server_ca_extensions ]
basicConstraints = CA:false
keyUsage = keyEncipherment

[ client_ca_extensions ]
basicConstraints = CA:false
keyUsage = digitalSignature" > $openssl_cnf

exit 0

cd $dir
echo "generate root ca: "
# in this step, I always input a password of "ca.key111111"
openssl genrsa -des3 -out root/ca.key 2048
# in this step, I always input these and a password of "ca.csr111111"
#Country Name (2 letter code) [XX]:CN
#State or Province Name (full name) []:Si Chuan
#Locality Name (eg, city) [Default City]:Cheng Du
#Organization Name (eg, company) [Default Company Ltd]:Yao Guai Ltd
#Organizational Unit Name (eg, section) []:Yao Guai
#Common Name (eg, your name or your server's hostname) []:yaoguai.com
#Email Address []:[email protected]
#A challenge password []:ca.csr111111
#An optional company name []:Yao Guai Ltd
openssl req -new -newkey rsa:2048 -key root/ca.key -out root/ca.csr
openssl x509 -req -days 3650 -in root/ca.csr -signkey root/ca.key -out root/ca.crt

echo "generate server keys: "
# in this step, I always input a password of "server.key111111"
openssl genrsa -des3 -out server/server.key 2048
# in this step, I always input these and a password of none
#Country Name (2 letter code) [XX]:CN
#State or Province Name (full name) []:Si Chuan
#Locality Name (eg, city) [Default City]:Cheng Du
#Organization Name (eg, company) [Default Company Ltd]:Yao Guai Ltd
#Organizational Unit Name (eg, section) []:Yao Guai
#Common Name (eg, your name or your server's hostname) []:yaoguai.com
#Email Address []:[email protected]
#A challenge password []:none
#An optional company name []:none
openssl req -new -newkey rsa:2048 -key server/server.key -out server/server.csr
openssl ca -config openssl.cnf -in server/server.csr -cert root/ca.crt -keyfile root/ca.key -out server/server.crt -days 3650

echo "generate client keys: "
# in this step, I always input a password of "client.key111111"
openssl genrsa -des3 -out client/client.key 2048
# in this step, I always input these and a password of none
#Country Name (2 letter code) [XX]:CN
#State or Province Name (full name) []:Si Chuan
#Locality Name (eg, city) [Default City]:Cheng Du
#Organization Name (eg, company) [Default Company Ltd]:Yao Guai Ltd
#Organizational Unit Name (eg, section) []:Yao Guai
#Common Name (eg, your name or your server's hostname) []:yaoguai.com
#Email Address []:[email protected]
#A challenge password []:none
#An optional company name []:none
openssl req -new -newkey rsa:2048 -key client/client.key -out client/client.csr
# to prevent error "openssl TXT_DB error number 2 failed to update database"
echo "unique_subject = no" > "index.txt.attr"
openssl ca -config openssl.cnf -in client/client.csr -cert root/ca.crt -keyfile root/ca.key -out client/client.crt -days 3650

# use these to config nginx
: <<EOF
    ssl on;
    ssl_verify_client on;
    ssl_certificate /tmp/ssl/server/server.crt;
    ssl_certificate_key /tmp/ssl/server/server.key;
    ssl_client_certificate /tmp/ssl/root/ca.crt;
    ssl_session_timeout 5m;
    ssl_protocols SSLv3 TLSv1 TLSv1.1 TLSv1.2;
    ssl_ciphers "HIGH:!aNULL:!MD5 or HIGH:!aNULL:!MD5:!3DES";
    ssl_prefer_server_ciphers on;
EOF

echo "helps:"
# 查看key文件簽名信息
echo "openssl rsa -in xxx.key -text -noout"
# 查看csr文件簽名
echo "openssl req -noout -text -in xxx.csr"
# 將pem格式轉換成der格式
echo "openssl x509 -in server/server.crt -outform DER -out server/server.cer"
# 使用curl請求服務器
echo 'curl -k --cert client/client.crt --key client/client.key --pass client.key111111 https://devel/index.php'
# 生成p12文件, password export111111
echo "openssl pkcs12 -export -in client/client.crt -inkey client/client.key -out client/client.p12 -certfile root/ca.crt"

cd $workspace

中間會要求輸入幾次證書信息和密碼, 需要自己嚴格保存下來, 如果泄漏也會產生通訊被破解的危險.

然後我們在nginx的server模塊中添加ssl相關的配置:

ssl on;
ssl_verify_client on;
ssl_certificate /tmp/ssl/server/server.crt;
ssl_certificate_key /tmp/ssl/server/server.key;
ssl_client_certificate /tmp/ssl/root/ca.crt;
ssl_session_timeout 5m;
ssl_protocols SSLv3 TLSv1 TLSv1.1 TLSv1.2;
ssl_ciphers "HIGH:!aNULL:!MD5 or HIGH:!aNULL:!MD5:!3DES";
ssl_prefer_server_ciphers on;

iOS使用AFNetworking實現SSL雙向認證

iOS端我們使用AFNetworking實現SSL雙向認證,服務器我們搭建在127.0.0.1,在7080端口開通ssl驗證. 但是實際使用服務器時一般會使用域名來指向具體的IP, 這裏我們通過修改/etc/hosts來實現將yaoguai.com指向127.0.0.1, 因爲我們的證書使用的是yaoguai.com這個Common Name來簽名的,所以如果我們的服務器不是yaoguai.com,那麼客戶端會造成驗證不通過.

而整個ssl的實現我就簡單放到AppDelegate.m文件中了. 整個項目地址在https-api.

#import <AFNetworking/AFNetworking.h>
#import "AppDelegate.h"
#define _AFNETWORKING_ALLOW_INVALID_SSL_CERTIFICATES_ 1

#ifndef    weakify
#if __has_feature(objc_arc)

#define weakify( x ) \
_Pragma("clang diagnostic push") \
_Pragma("clang diagnostic ignored \"-Wshadow\"") \
autoreleasepool{} __weak __typeof__(x) __weak_##x##__ = x; \
_Pragma("clang diagnostic pop")

#else

#define weakify( x ) \
_Pragma("clang diagnostic push") \
_Pragma("clang diagnostic ignored \"-Wshadow\"") \
autoreleasepool{} __block __typeof__(x) __block_##x##__ = x; \
_Pragma("clang diagnostic pop")

#endif
#endif

#ifndef    strongify
#if __has_feature(objc_arc)

#define strongify( x ) \
_Pragma("clang diagnostic push") \
_Pragma("clang diagnostic ignored \"-Wshadow\"") \
try{} @finally{} __typeof__(x) x = __weak_##x##__; \
_Pragma("clang diagnostic pop")

#else

#define strongify( x ) \
_Pragma("clang diagnostic push") \
_Pragma("clang diagnostic ignored \"-Wshadow\"") \
try{} @finally{} __typeof__(x) x = __block_##x##__; \
_Pragma("clang diagnostic pop")

#endif
#endif

@interface AppDelegate ()

@end

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    // Override point for customization after application launch.
    [self setHttpsVerifyAndRequest];
    return YES;
}

- (void)applicationWillResignActive:(UIApplication *)application {
    // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
    // Use this method to pause ongoing tasks, disable timers, and throttle down OpenGL ES frame rates. Games should use this method to pause the game.

}

- (void)applicationDidEnterBackground:(UIApplication *)application {
    // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
    // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.

}

- (void)applicationWillEnterForeground:(UIApplication *)application {
    // Called as part of the transition from the background to the inactive state; here you can undo many of the changes made on entering the background.
}

- (void)applicationDidBecomeActive:(UIApplication *)application {
    // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
}

- (void)applicationWillTerminate:(UIApplication *)application {
    // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
}

- (void) setHttpsVerifyAndRequest {
    NSString *certFilePath = [[NSBundle mainBundle] pathForResource:@"server" ofType:@"cer"];
    NSData *certData = [NSData dataWithContentsOfFile:certFilePath];
    NSSet *certSet = [NSSet setWithObject:certData];
    AFSecurityPolicy *policy = [AFSecurityPolicy policyWithPinningMode:AFSSLPinningModeCertificate withPinnedCertificates:certSet];
    policy.allowInvalidCertificates = NO;
    policy.validatesDomainName = YES;

    AFHTTPSessionManager * manager = [AFHTTPSessionManager manager];
    manager.securityPolicy = policy;
    manager.requestSerializer = [AFHTTPRequestSerializer serializer];
    manager.responseSerializer = [AFHTTPResponseSerializer serializer];
    //manager.responseSerializer.acceptableContentTypes = [NSSet setWithObjects:@"application/json", @"text/json", @"text/html", @"text/javascript", @"text/plain", nil];

    manager.requestSerializer.cachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
    [manager setSessionDidBecomeInvalidBlock:^(NSURLSession * _Nonnull session, NSError * _Nonnull error) {
        NSLog(@"setSessionDidBecomeInvalidBlock %@", error);
    }];

    _manager = manager;

    @weakify(self);
    [manager setSessionDidReceiveAuthenticationChallengeBlock:^NSURLSessionAuthChallengeDisposition(NSURLSession*session, NSURLAuthenticationChallenge *challenge, NSURLCredential *__autoreleasing*_credential) {
        @strongify(self);
        NSURLSessionAuthChallengeDisposition disposition = NSURLSessionAuthChallengePerformDefaultHandling;
        __autoreleasing NSURLCredential *credential =nil;
        if([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
            if([self.manager.securityPolicy evaluateServerTrust:challenge.protectionSpace.serverTrust forDomain:challenge.protectionSpace.host]) {
                credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];
                if(credential) {
                    disposition =NSURLSessionAuthChallengeUseCredential;
                } else {
                    disposition =NSURLSessionAuthChallengePerformDefaultHandling;
                }
            } else {
                disposition = NSURLSessionAuthChallengeCancelAuthenticationChallenge;
            }
        } else {
            // client authentication
            SecIdentityRef identity = NULL;
            SecTrustRef trust = NULL;
            NSString *p12 = [[NSBundle mainBundle] pathForResource:@"client"ofType:@"p12"];
            NSFileManager *fileManager =[NSFileManager defaultManager];

            if(![fileManager fileExistsAtPath:p12])
            {
                NSLog(@"client.p12:not exist");
            }
            else
            {
                NSData *PKCS12Data = [NSData dataWithContentsOfFile:p12];

                if ([[self class]extractIdentity:&identity andTrust:&trust fromPKCS12Data:PKCS12Data])
                {
                    SecCertificateRef certificate = NULL;
                    SecIdentityCopyCertificate(identity, &certificate);
                    const void*certs[] = {certificate};
                    CFArrayRef certArray =CFArrayCreate(kCFAllocatorDefault, certs,1,NULL);
                    credential =[NSURLCredential credentialWithIdentity:identity certificates:(__bridge  NSArray*)certArray persistence:NSURLCredentialPersistencePermanent];
                    disposition =NSURLSessionAuthChallengeUseCredential;
                }
            }
        }
        *_credential = credential;
        return disposition;
    }];

    [manager GET:@"https://yaoguai.com:7080/index.php" parameters:nil progress:nil success:^(NSURLSessionTask *task, id responseObject) {
        NSLog(@"Data: %@", [[NSString alloc] initWithData:responseObject encoding:NSUTF8StringEncoding]);
    } failure:^(NSURLSessionTask *operation, NSError *error) {
        NSLog(@"Error: %@", error);
    }];
}

+(BOOL)extractIdentity:(SecIdentityRef*)outIdentity andTrust:(SecTrustRef *)outTrust fromPKCS12Data:(NSData *)inPKCS12Data {
    NSDictionary*optionsDictionary = @{(__bridge id) kSecImportExportPassphrase : @"export111111"};

    CFArrayRef items = CFArrayCreate(NULL, 0, 0, NULL);
    OSStatus securityError = SecPKCS12Import((__bridge CFDataRef)inPKCS12Data,(__bridge CFDictionaryRef)optionsDictionary,&items);

    if(securityError == 0) {
        CFDictionaryRef myIdentityAndTrust =CFArrayGetValueAtIndex(items,0);
        const void*tempIdentity =NULL;
        tempIdentity= CFDictionaryGetValue (myIdentityAndTrust,kSecImportItemIdentity);
        *outIdentity = (SecIdentityRef)tempIdentity;
        const void*tempTrust =NULL;
        tempTrust = CFDictionaryGetValue(myIdentityAndTrust,kSecImportItemTrust);
        *outTrust = (SecTrustRef)tempTrust;
    } else {
        NSLog(@"Failedwith error code %d",(int)securityError);
        return NO;
    }
    return YES;
}
@end

服務端的文件也很簡單, 就是在網站根目錄下放置一個index.php, 其中的內容爲:

<?php
echo 'Hello World!';

最終我們可以看到在控制檯返回"Hello World!".

瀏覽器加載客戶端證書實現訪問

我這裏使用的Mac筆記本, 找到"鑰匙串訪問", 在"鑰匙串" > "系統" 中選擇添加鑰匙串, 添加我們生成的client.p12文件, 並點擊證書, 在詳情中將認證設置爲"始終信任".

然後在瀏覽器中訪問"https://yaoguai.com:7080/index.php", 系統會要求你輸入相應的密碼. 然後我們就可以在瀏覽器中看到輸出"Hello World!"

charles加載客戶端證書實現調試

我們打開charles, 勾選頂部菜單"Proxy" > "Mac OS X Proxy"啓動代理, 然後點擊"Proxy" > "SSL Proxying Settings..", 在"SSL Proxying"頁點擊Add, 添加一項"yaoguai.com:7080".

然後在"Client Certificates"頁點擊Add, 添加一項"yaoguai.com:7080",並添加client.p12文件.

當電腦或者代理到charles的手機訪問yaoguai.com時, 會提示你輸入client.p12的導出密碼.

這時我們打開charles, 並運行"https-api"項目, 就可以在charles中截獲"get https://yaoguai.com:7080/index.php"請求了.

Android使用okhttp實現SSL雙向認證

Android方面我們使用okhttp向服務器發起請求, 這裏我們使用封裝了okhttp的OkHttpUtils庫, 因爲它已經實現了SSL的雙向認證.

由於一直在Android虛擬機中修改hosts文件失敗, 我就使用自己的服務器jegarn.com重新生成了簽名.

需要注意的是, 我們生成的是p12文件, 但是Android需要的是bks文件, 我這裏寫了一個pkcs12轉bks的函數, 就統一了iOS和Android的證書. 另外一點就是轉換的時候有個alias參數, 傳入錯誤的話會導致生成的文件不正確, 可以通過下面的代碼打印出來. 實際實現的時候是取出pkcs12證書中的第一個alias作爲bks證書的alias.

Enumeration<String> aliases = pkcs12.aliases();
while(aliases.hasMoreElements()){
    System.out.println("alias: " + aliases.nextElement());
}

整個驗證的過程我也直接寫到了MainActivity.java文件中, 完整的源代碼可以在這裏找到 https-api.

package com.jegarn.https_api;

import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;

import com.zhy.http.okhttp.OkHttpUtils;
import com.zhy.http.okhttp.callback.StringCallback;
import com.zhy.http.okhttp.https.HttpsUtils;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.security.Key;
import java.security.KeyStore;
import java.security.cert.Certificate;
import java.util.Enumeration;
import java.util.concurrent.TimeUnit;

import okhttp3.Call;
import okhttp3.OkHttpClient;

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        this.sslRequest();
    }

    protected void sslRequest() {

        // wiki: http://blog.csdn.net/lmj623565791/article/details/48129405

        InputStream certificates = getResources().openRawResource(R.raw.server);
        InputStream pkcs12File = getResources().openRawResource(R.raw.client);
        String password = "export111111";
        InputStream bksFile = this.pkcs12ToBks(pkcs12File, password);
        HttpsUtils.SSLParams sslParams = HttpsUtils.getSslSocketFactory(new InputStream[]{certificates}, bksFile, password);

        OkHttpClient okHttpClient = new OkHttpClient.Builder()
                .connectTimeout(10000L, TimeUnit.MILLISECONDS)
                .readTimeout(10000L, TimeUnit.MILLISECONDS)
                .sslSocketFactory(sslParams.sSLSocketFactory, sslParams.trustManager)
                .build();
        OkHttpUtils.initClient(okHttpClient);

        String url = "https://jegarn.com:7080/index.php";
        OkHttpUtils
                .get()
                .url(url)
                .build()
                .execute(new StringCallback() {
                    @Override
                    public void onError(Call call, Exception e, int id) {
                        e.printStackTrace();
                    }

                    @Override
                    public void onResponse(String response, int id) {
                        System.out.println(response);
                    }
                });
    }

    protected InputStream pkcs12ToBks(InputStream pkcs12Stream, String pkcs12Password) {
        final char[] password = pkcs12Password.toCharArray();
        try {
            KeyStore pkcs12 = KeyStore.getInstance("PKCS12");
            pkcs12.load(pkcs12Stream, password);
            Enumeration<String> aliases = pkcs12.aliases();
            String alias;
            if (aliases.hasMoreElements()) {
                alias = aliases.nextElement();
            } else {
                throw new Exception("pkcs12 file not contain a alias");
            }
            Certificate certificate = pkcs12.getCertificate(alias);
            final Key key = pkcs12.getKey(alias, password);
            KeyStore bks = KeyStore.getInstance("BKS");
            bks.load(null, password);
            bks.setKeyEntry(alias, key, password, new Certificate[]{certificate});
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            bks.store(out, password);
            return new ByteArrayInputStream(out.toByteArray());
        } catch (Exception e) {
            e.printStackTrace();
        }

        return null;
    }
}

運行項目,即可看到控制檯輸出"Hello World!".

至此, 移動端iOS和Android的SSL雙向認證都成功完成了.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章