iOS9からWKWebViewのSSL/TLS接続はコードで制御する


本記事はベータ版について書かれています。
一般に流通しているiOS製品(iPad / iPhone / iPod touch)には該当しません。
また、将来下記の内容がiOSに反映されるとは限りません。

SSL/TLS認証でWKNavigationDelegateが呼ばれるように

iOS9からWKWebViewでSSL/TLS接続をする時に
WKNavigationDelegate の - webView:didReceiveAuthenticationChallenge:completionHandler:が呼ばれるようになるようです。

ここで、SSL/TLS接続([[[challenge protectionSpace] authenticationMethod] isEqualToString:NSURLAuthenticationMethodServerTrust])のケースを正しくさばくことが出来ないと、SSL/TLS接続することが出来ません。

ただし、そもそも- webView:didReceiveAuthenticationChallenge:completionHandler:のデリゲートメソッドを実装していない場合は、デフォルト処理で勝手に接続を確立してくれるようです。

iOS9で- webView:didReceiveAuthenticationChallenge:completionHandler:が実装されているにもかかわらずNSURLAuthenticationMethodServerTrustのケースを制御する実装がない場合、SSL/TLS接続を試みるとThe operation couldn’t be completed. 
 (NSURLErrorDomain error -999.)が発生します。

なぜ

NSURLConnectionやNSURLSessionの場合は、従来からSSL/TLS認証でデリゲートがコールされ、コードによって制御ができるようになっています。

コードで制御することが出来ると、検証に失敗したサーバー証明書についても、強制的に接続を確立することが出来ます。

たとえば外部にhttps://で公開しているサーバーにローカルからIPを直接叩いて接続した場合、証明書のエラーが発生し、接続できません。iOS8のWKWebViewではこのようなサイトに接続させる方法がない(ただしMercuryというブラウザアプリは、どうやってか接続させる方法を実装している)のですが、NSURLConnection、NSURLSession、iOS9以降のWKWebViewのデリゲートメソッド中で独自に制御することが出来れば、強制的に信頼して接続を確立させることが出来ます。

Safariでも、証明書のエラーが出るとアラートが出ますが、それでも信頼して接続する場合は「続ける」という選択肢を選ぶことで強制的に接続を確立させることが出来ます。このような実装を、WKWebViewでも出来るようになります。

実装方法

Cocoa勉強会関東より、NSURLSessionの場合の実装例
Cocoa勉強会#62-新しい通信クラス群NSURLSessionを使ってみる

Apple のNSURLConnection向けドキュメントより、NSURLConnectionの場合の実装例
Overriding TLS Chain Validation Correctly

サンプルコード

※SSL/TLS認証時のケースのみ。Basic認証等、他の認証のケースはそれぞれ必要に応じて実装して下さい。

if ([authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]){

        SecTrustRef secTrustRef = challenge.protectionSpace.serverTrust;
        if (secTrustRef != NULL) {
            SecTrustResultType result;
            OSErr er = SecTrustEvaluate(secTrustRef, &result);
            if (er != noErr) {
                completionHandler(NSURLSessionAuthChallengeRejectProtectionSpace, nil);
                return;
            }
            if (result == kSecTrustResultRecoverableTrustFailure) {
                //信頼出来ない証明書
                CFArrayRef secTrustProperties = SecTrustCopyProperties(secTrustRef);
                NSArray *arr = CFBridgingRelease(secTrustProperties);
                NSMutableString *errorStr = [NSMutableString string];
                for (int i=0; i<arr.count; i++) {
                    NSDictionary *dic = [arr objectAtIndex:i];
                    if (i != 0 ) [errorStr appendString:@" "];
                    [errorStr appendString:(NSString*)dic[@"value"]];
                }

                SecCertificateRef certRef = SecTrustGetCertificateAtIndex(secTrustRef, 0);
                CFStringRef cfCertSummaryRef =  SecCertificateCopySubjectSummary(certRef);
                NSString *certSummary = (NSString *)CFBridgingRelease(cfCertSummaryRef);
                NSString *title = @"Cannot Verify Server Identity";
                NSString *message = [NSString stringWithFormat:@"The identity of “%@” cannot be verified by %@. The certificate is from “%@”. \n%@", hostName, @"YOUR_APPNAME", certSummary, errorStr];
                UIAlertController *alertController = [UIAlertController alertControllerWithTitle:title message:message preferredStyle:UIAlertControllerStyleAlert];
                // キャンセルボタン
                [alertController addAction:[UIAlertAction actionWithTitle:@"Cancel"
                                                                    style:UIAlertActionStyleDefault
                                                                  handler:^(UIAlertAction *action) {
                                                                      completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, nil);}]
                 ];
                // 続けるボタン
                [alertController addAction:[UIAlertAction actionWithTitle:@"Continue" style:UIAlertActionStyleCancel handler:^(UIAlertAction *action) {
                    NSURLCredential* credential = [NSURLCredential credentialForTrust:secTrustRef];
                    completionHandler(NSURLSessionAuthChallengeUseCredential, credential);

                }]];
                dispatch_async(dispatch_get_main_queue(), ^{
                    [_delegate presentViewController:alertController animated:YES completion:^{}];
                });
                return;
            }
            NSURLCredential* credential = [NSURLCredential credentialForTrust:secTrustRef];
            completionHandler(NSURLSessionAuthChallengeUseCredential, credential);
            return;
        }
        completionHandler(NSURLSessionAuthChallengeRejectProtectionSpace, nil);
    }

YOUR_APPNAME の部分は、Safariだと"Safari"と表示されている部分です。自身のアプリ名を入れると良いと思います。

Safariの場合、アラートにはさらに"詳細"という選択肢があります。詳細では、証明書の内容を確認することが出来ます。iOSでSSL証明書についての情報を取得しようとすると、標準の機能では限界があり、Safari同様に証明書の内容を確認できるような機能を実装する場合、OpenSSLをアプリに組込み、独自に証明書を処理する必要がありそうです。

参考:UIWebViewの場合

UIWebViewは内部的にNSURLConnection(おそらくiOS7以降はNSURLSession)を利用しています。SSL/TLS認証は一度強制的に信頼すれば、以降はエラーとならずに接続を確立できるようになるため、NSURLConnection/NSURLSessionでSSL/TLS接続を一度確立させ、UIWebViewで再度接続する手順が一般的です。

UIWebViewでSSL/TLS接続に失敗した場合、どうするかユーザーに確認し、接続を続行する場合は、一度NSURLConnection/NSURLSessionで強制的にSSL/TLS接続を実行し、再度UIWebViewで接続を試みます。

この方法は、WKWebViewには使えないようです(iOS8で検証)。

参考
今 Swift や iOS 8 について書くのは NDA 違反か調べてみた - Qiita