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. クライアントから、TLSに切り替える旨をサーバへ要求する(STARTTLS要求を送る)
    3. サーバから、受理したからどうぞ、という応答が来る(220)
  3. TLSで接続する
  4. 通信を開始する(メールを送受信する)

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

...こういう「途中から切り替える」実例が当時見つからなかったので、メモしておきました。

TCPにせよSSL/TLSにせよ、そもそも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では動かせない仕様だった気がするので(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側で拾わないような工夫を頑張って入れましょう。