AndroidJava/TCPからSSLへの途中切り替え

TCPからSSLへ切り替える(2019-08-12)




概要

STARTTLSとか、接続済みのTCPセッションを「途中から」SSL/TLSに切り替える話です。
何を言っているのかさっぱりワカラナイヨ
という方に向けて、ふわっと説明します。(説明不要な方は読み飛ばして下さい)

ふつう、httpsの通信とかはこんな流れです

  1. TCPで接続する
  2. SSL/TLSで接続する
  3. 通信を開始する(HTTP GET して 200 OK をもらう)

そして、STARTTLSとか「途中から」SSLへ切り替えるというのは

  1. TCPで接続する
  2. TCPで通信する
    1. クライアントから最初の挨拶をしたら(EHLOを送る)、
      サーバからの応答(250)で"STARTTLS"も使えることを知る
    2. クライアントから、SSLに切り替える旨をサーバへ要求する(STARTTLS要求を送る)
    3. サーバから、受理したからどうぞ、という応答が来る(220)
  3. SSL/TLSで接続する
  4. 通信を開始する(メールを送受信する)

この例で始まりの1, 2の部分は平文で(暗号化されていない通信)
3以降のSSLで繋いだ後は、暗号化されたいわゆる安全な通信です。
開始時にサーバ側とクライアント側、双方の状態をお互いに確認して、
可能であればSSLや移行する、
互いの条件が一致しない、あるいは暗号化は不要ならそのままTCPでやりとり、
ということができます。

...こういう例が当時見つからなかったので、メモしておきます。
TCPにせよSSLにせよ、そもそも1から自分でつくる必要はなくて
http接続するためのライブラリや、smtpする為のライブラリが存在していて、
その中でSSLやらSTARTTLSやらもまとめてやってくれるので、
そりゃあサンプルも見つからないと思いますが...

何か自力で実装したい特殊な事情を抱えた方は、以下参考になれば...

コードの例

まず、ソースコードから。
クライアント側の例です。サーバ側だと更にsetUseClientModeの要否を確認とかも、よしなに。

// TCPのソケットを作る
SocketFactory sf = SocketFactory.getDefault();
Socket tcp_soc = sf.createSocket(host, port);
InputStream tcp_in  = tcp_soc.getInputStream();
OutputStream tcp_out = tcp_soc.getOutputStream();
 
// この時点で tcp_in/tcp_out を使って送受信ができるが、
// ここから更に、同じソケットをSSLへと切り替える
 
SSLSocketFactory sf2 = (SSLSocketFactory) SSLSocketFactory.getDefault();
SSLSocket ssl_soc = (SSLSocket)sf2.createSocket(
            tcp_soc,
            tcp_soc.getInetAddress().getHostAddress(),
            tcp_soc.getPort(),
            true);
ssl_soc.startHandshake();
/* 以降、それっぽいSSLの処理を入れる
 * SSLSession ssl_ses = ssl_soc.getSession();
 * InputStream ssl_in  = ssl_soc.getInputStream();
 * ssl_out = ssl_soc.getOutputStream();
 * など
 */

Androidで上記を実装する場合は、
メインThreadでは動かせない仕様だった気がするので(UI スレッド、とか?だとExceptionになるかと)
TCPの送受信を行うスレッド(tcp_in/tcp_outを読み書きする)やら
SSLの送受信を行うスレッド(ssl_in/ssl_outを読み書きする)やらを別に作ることになります。
getInputStream/getOutputStream の読み書きの方法や、
Threadの動かし方についてはここではメモしません。
(それだけで1つ別の話題として、詳細なサイトがイッパイあるので)

私の最初の失敗として
SocketFactoryと、SSLSocketFactory を間違えて実装してしまいました。
というのも、ふつうにSSL接続する例としては、こうやると思うのですが、

// TCPから切り替えではなく、直接SSL接続する例
SocketFactory sf = SSLSocketFactory.getDefault();
ssl_soc = (SSLSocket) sf.createSocket(host, port);

ここでSocketFactoryを使っているので、勘違いしてTCP〜SSLへの切り替えでも
同じように(SSLSocketFactory ではなく)SocketFactory.createSocketを使って
...おや、socketなんて引数に渡せないぞ!?
と、しばらくの間うなっていました。

SSLハンドシェイクをTCPソケットでreadしてしまった件

上記の内容を踏まえて、さっそく実装しそこねたのですが、
切替後の、startHandshakeに失敗していました。
(javax.net.ssl.SSLHandshakeException: Handshake failed)
このException から getCause() したメッセージをLog.d で出力してみたら、

Failure in SSL library, usually a protocol error

これ、たしか以前に別件で
(Webサーバでsquidの設定間違えて、SSL接続とTCP接続がごっちゃになってた時に)
httpポートめがけてSSL接続した時に、似たような症状になった気がします...
そして、さらに色々出力してみたlogcatに、
数字と文字化けした文字列...どうやらSSLハンドシェイクのメッセージが、TCPの方へ流れてしまっている?

TCP接続して送受信する際のイメージとして

 SocketFactory sf = SocketFactory.getDefault();
 tcp_soc = sf.createSocket(host, port);
 InputStream tcp_in  = tcp_soc.getInputStream();
 tcp_out = tcp_soc.getOutputStream();
 byte[] tmp = new byte[1024];
 while(true){
     int r_len = tcp_in.read(tmp, 0, 1024); // ここでブロックするので注意
     if(r_len<0) break; // 終了の場合
     //--- 以下、r_lenサイズ分、tmp から読み取る
 }
 //--- 以下、closeしたりする

この状態から前述のように、
別スレッドで tcp_soc をSSLへ切り替えてしまうと、
SSLで処理するべき情報(SSLハンドシェイク)も全部こちらの read() へ流れていってしまいました。

つまり「途中で切り替える」には、
TCPのread()も途中で止めないといけません。
以下、その案。

 // クラス変数として boolean is_already_change_to_ssl; を用意しておいて
 while(true){
    if(is_already_change_to_ssl){ // SSLへ変わったかどうか、フラグを別途立てる
        break;
        // TCPとしてのスレッドは終了、ただし、
        // ソケットやinputStreamは closeしないこと、SSL側で使っているので。
    }
    if( (tcp_soc != null && ! tcp_soc.isClosed() ) // 通信は継続中で、
        && tcp_in.available() <= 0  // データ数が0以下ならリトライさせる
            ){
        try{Thread.sleep(100);} // 数字は適当なミリ秒数
        catch (Exception e){} //よしなに
        continue;
    }
    int r_len = tcp_in.read(tmp, 0, 1024); // ここでブロックするので注意
    // 以下はよしなに
    // 通常のTCP読み取り後の処理
 }

発想としては、read()のようなブロックする段階で判定すると手遅れなので、
TCPなのかSSLなのかをブロックしない処理で、逐一判定したい、という案です。

いちいち100ミリ秒 sleepするのがモヤっとするので、もう少し探してみると、
(Web検索で、socket とかread とかを how to interrupt する例を探して)
別案として Thread.interrupt() して割り込むという案がありました。
ただその場合は、先達たちの解説内容を見るに、
環境依存や、Javaのバージョンやら...きっちり吟味/検証しないと想定外の動作になる雰囲気は、あります。

方法は、まぁ、何でもいいので、
TCPとSSLを途中で切り替える(混在させる)なら、
SSL側の処理をTCP側で拾わないような工夫を頑張って入れましょう。