⑧ではOpenSSLで接続を観察し、前回⑨では鍵の作り分けと証明書・署名による認証を追いました。今回は、証明書の名前や信頼の起点が違うと、どこで接続が止まるのかを確かめます。続いて、C#から見えるTLSの境目と、以前のIPsecのログに並んでいた方式を追います。道具やプロトコルが変わっても、方式を合わせ、秘密を共有し、相手を確かめる仕事を分けて考えられます。

暗号化と認証

 暗号化と認証⑩

 証明書の検証と、C#・IPsecから見る接続

⑧ではOpenSSLで接続を観察し、前回⑨では鍵の作り分けと証明書・署名による認証を追いました。今回は、証明書の名前や信頼の起点が違うと、どこで接続が止まるのかを確かめます。

続いて、C#から見えるTLSの境目と、以前のIPsecのログに並んでいた方式を追います。道具やプロトコルが変わっても、方式を合わせ、秘密を共有し、相手を確かめる仕事を分けて考えられます。

OpenSSLの実験には、⑧「手元で再現するためのサーバー準備」で用意したCA・サーバー証明書・秘密鍵を使います。

CAを信頼していても、名前が違えば通らない

ここまで見たので、条件を一つだけ変えてみます。

CAは⑧の接続と同じものを信用したまま、確認するホスト名を変えます。

Bash
openssl s_client \
  -connect 127.0.0.1:18443 \
  -servername localhost \
  -verify_hostname wrong.example \
  -CAfile lab-ca.crt \
  -verify_return_error \
  -tls1_3 < /dev/null

接続するサーバーも、サーバーへ伝える名前も、実験用CAも同じです。

変えたのは、クライアントが「この名前の相手であることを確認したい」と指定する部分です。

今回の証明書のSANにはDNS:localhostを入れています。wrong.exampleは入っていません。

結果は、hostname mismatchで停止しました。

つまり、「信用しているCAが署名した証明書です」だけでは、接続したい名前の相手だと確認したことにはなりません。

ここで-servernameと-verify_hostnameの違いも見えてきます。

オプション役割
-servername localhostサーバーへ、どの名前のサイトを求めているか伝える
-verify_hostname localhost受け取った証明書が、その名前に対応しているか検証する

さらに、-CAfile lab-ca.crtを外すと、今度はunable to get local issuer certificateで停止しました。

実験用CAは、OpenSSLが通常使う信頼済みCAには登録していないためです。

実験の条件結果
CAを信用し、localhostを確認する検証成功。HTTPの応答まで受信
CAを信用し、wrong.exampleを確認するホスト名の不一致で停止
実験用CAを信用する指定を外す発行元を信頼の起点まで確認できず停止

⑤で扱った「誰を信用するのか」と、今回の「いま誰へ接続しているのか」が、一つの接続の中で効いています。

C#からも、同じ境目を見る

OpenSSLから見た流れを、C#側でも追うにはどうしたらよいでしょう。

普段のHTTP処理なら、HttpClientがHTTPやTLSの処理をまとめて扱ってくれます。

今回は、TCPの接続とTLSの接続の境目を見るため、TcpClientとSslStreamを使う確認用コードを用意します。

以下は.NET 8以降のコンソールプロジェクトを想定した例です。接続先は公開サイトのbricklayer.workにしています。

C#
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Authentication;
using System.Text;

const string host = "bricklayer.work";

using var tcp = new TcpClient();
await tcp.ConnectAsync(host, 443);

using var tls = new SslStream(
    tcp.GetStream(),
    leaveInnerStreamOpen: false);

await tls.AuthenticateAsClientAsync(
    new SslClientAuthenticationOptions
    {
        TargetHost = host,
        EnabledSslProtocols = SslProtocols.None,
        ApplicationProtocols = new List<SslApplicationProtocol>
        {
            SslApplicationProtocol.Http11
        }
    });

Console.WriteLine($"TLS: {tls.SslProtocol}");
Console.WriteLine($"Cipher suite: {tls.NegotiatedCipherSuite}");
Console.WriteLine($"ALPN: {tls.NegotiatedApplicationProtocol}");
Console.WriteLine($"Authenticated: {tls.IsAuthenticated}");
Console.WriteLine($"Encrypted: {tls.IsEncrypted}");

string request =
    "GET /techblog/ HTTP/1.1\r\n" +
    $"Host: {host}\r\n" +
    "Connection: close\r\n\r\n";

await tls.WriteAsync(Encoding.ASCII.GetBytes(request));

using var reader = new StreamReader(
    tls,
    Encoding.UTF8,
    detectEncodingFromByteOrderMarks: false,
    bufferSize: 1024,
    leaveOpen: true);

Console.WriteLine(await reader.ReadLineAsync());

ConnectAsync()でTCP接続を作り、AuthenticateAsClientAsync()でTLSのハンドシェイクを行います。その後、SslStreamへ書き込むHTTPのデータがTLSで保護されます。[1]

TargetHostは、単なる表示用の文字列ではありません。接続したいサーバーの名前として、証明書の検証やSNIに関係します。[2]

また、SslProtocols.Noneは「TLSを使わない」という指定ではありません。使うTLSのバージョンをOS側の選択に任せる指定です。実際に選ばれたバージョンをSslProtocolで見ます。[3]

今回のOpenSSL実験ではTLS 1.3へ固定しましたが、このC#例では接続先と実行環境が対応するバージョンを観察します。TLS 1.3へ固定して比較する場合は、SslProtocols.Tls13へ変更します。対応しない環境との接続は失敗します。

ALPNではHTTP/1.1を提示しています。この例では、自分でHTTP/1.1の文字列を書き込むためです。応答は先頭のステータス行だけを表示します。

NegotiatedCipherSuiteは、ハンドシェイクで実際に決まった暗号スイートを返します。読むのは、認証処理が成功した後です。[4]

証明書の検証を無条件に通すコールバックは設定していません。信頼されない証明書や、名前が合わない証明書なら、通常の検証で接続に失敗します。

証明書の検証エラーを消すために、検証コールバックで無条件にtrueを返して本番運用しないでください。暗号化が成功しても、通信したい相手だと確認できたことにはなりません。

自宅ラボのCAを使うなら、⑤で見たように信頼の起点を用意します。必要に応じて.NETのCertificateChainPolicyで、アプリケーションが信頼するCAを指定する方法もあります。[3]

いつものC#から見ると、呼び出しはそれほど長くありません。

でも、そのAuthenticateAsClientAsync()の中で、今回追ってきた鍵交換、証明書の検証、署名の確認、Finishedの照合が進んでいます。

IPsecのログでも、方式を合わせていた

⑧で整理したClientHelloの候補一覧と、今回の接続結果を見ていると、以前のIPsec接続のログを思い出しました。記憶では、それまでつながっていたIKEv1の接続がつながらなくなり、調べたときに見たログです。

IKEv1でも、相手が使える方式を提案し、こちらが設定している方式と合うかを確かめていました。

当時の失敗ログには、received proposalsとconfigured proposalsが並んでいます。長い候補一覧から一部を抜き出し、時刻やアドレスを省いて並べ直すと、こんな内容です。

received proposals:
  IKE:3DES_CBC/HMAC_SHA1_96/PRF_HMAC_SHA1/MODP_1024
  IKE:DES_CBC/HMAC_SHA1_96/PRF_HMAC_SHA1/MODP_1024
  …ほかの候補は省略…

configured proposals:
  IKE:AES_CBC_256/HMAC_SHA1_96/PRF_HMAC_SHA1/MODP_2048
  IKE:AES_CBC_128/HMAC_SHA1_96/PRF_HMAC_SHA1/MODP_2048
  IKE:AES_CBC_256/HMAC_SHA1_96/PRF_HMAC_SHA1/MODP_1024

no proposal found

received proposalsは相手が提案した組み合わせ、configured proposalsはこちらが許可している組み合わせです。

この接続要求で表示されている相手の候補はDESか3DES。こちらの設定はAESです。HMACやDHグループに一致する部分があっても、必要な条件が一式揃う組み合わせがありません。

そこで、no proposal foundになっています。

ただし、このログだけでは、以前どの方式で接続できていたのか、どちらの設定や実装が変わったのかまでは分かりません。ソフトウェアの更新で古い方式が無効になったと、原因を断定することもできません。分かるのは、この接続要求では双方の候補が合わなかったことです。

認証に使う秘密が正しいかを確かめる前に、まず使う方式の合意で止まっていました。IKEv1のMain Modeでは、この提案に合意した後に、DHの公開情報や乱数の交換へ進みます。[5]

当時使った調査用のスクリプトも残っていました。これは、接続先がどの組み合わせに応じるかを、一つずつ調べるものです。

Bash
#!/bin/sh

# Encryption algorithms: 3des=5, aes128=7/128, aes192=7/192, aes256=7/256
ENCLIST="5 7/128 7/192 7/256"
# Hash algorithms: md5=1, sha1=2, sha256=5, sha384=6, sha512=7
HASHLIST="1 2 5 6 7"
# Diffie-Hellman groups: 1, 2, 5, 14, 15, 19, 20, 21
GROUPLIST="1 2 5 14 15 19 20 21"
# Authentication method: Preshared Key=1
AUTH=1

for ENC in $ENCLIST; do
   for HASH in $HASHLIST; do
       for GROUP in $GROUPLIST; do
          echo ike-scan --trans=$ENC,$HASH,$AUTH,$GROUP -M "$@"
          ike-scan --trans=$ENC,$HASH,$AUTH,$GROUP -M "$@"
       done
   done
done

暗号方式4種類、ハッシュ5種類、DHグループ8種類。認証方法をPSKに固定し、接続先ごとに160通りの組み合わせを試すループです。

--transに並べているのは、暗号方式、ハッシュ、認証方法、DHグループです。-Mは結果を複数行で表示する指定で、Main Modeは既定の動作です。[6]

ここで見ている応答は、提案した方式を相手が受け入れるかどうかです。AUTH=1はPSK認証方式の指定で、PSKそのものを渡して認証を完了する指定ではありません。応答があっても、それだけでVPN接続成功とは言えません。

成功した別の接続のサーバーログでは、選ばれた方式がこう記録されていました。

selected proposal: IKE:AES_CBC_256/HMAC_SHA1_96/PRF_HMAC_SHA1/MODP_2048
表示担当している仕事
AES_CBC_256AESの256bit鍵を、CBC方式で使って暗号化する
HMAC_SHA1_96整合性確認の方式。96は確認値の長さを表す
PRF_HMAC_SHA1鍵の導出や認証の計算に、HMAC-SHA1を使う
MODP_20482048bitの有限体DHグループで、秘密を共有する

暗号化、確認、鍵の導出、鍵交換。別々の仕事が、一行の中へ並んでいます。[5][7]

その後には、実際のIPsecデータ通信を守るESPの方式も選ばれていました。

selected proposal: ESP:AES_CBC_256/HMAC_SHA1_96/NO_EXT_SEQ

IKEの交渉を守る鍵と、実際のデータを守る鍵は別に用意します。IPsecのデータ用の鍵は、送信方向ごとにも分かれます。用途を分けて鍵を作る話も、ここへつながっていました。[5]

今回のログではPSKを使って相手を認証しており、TLSの証明書認証と手順が全部同じではありません。それでも、使う方式を合わせ、秘密を共有し、相手を確かめて、通信を守るという仕事には共通点があります。

TLS側で分けて考えた仕事が、昔のIPsecのログにも、方式の名前として並んでいました。

十回分の話が、一つの接続につながった

①では、「読まれない」と「相手が本物」は別の話だと考えました。②では、公開鍵による暗号化と署名を分け、公開鍵をすり替えられたらどうなるかを追いました。

③と④では、RSAを小さな数字から計算し、本物の秘密鍵にも、その計算に使う値が入っていることを確かめました。⑤では、その公開鍵を誰が保証し、どこを信頼の起点にするのかを追いました。

⑥の入口は、古典的なSSL通信の説明に付いていた但し書きでした。「公開鍵で共通鍵の材料を暗号化して渡す」という説明が、現在のTLSではそのまま当てはまらない。そこから、「通信を守る鍵を、離れた相手とどう共有するのか」という鍵配送問題へ戻りました。

⑦では、RSA鍵輸送とDH系の方式が長く併存していたことを整理しました。SSLからTLSへの変化は、鍵共有だけの歴史ではありません。通信内容を守る方法や、用途別に鍵を作る方法も、並行して見直されてきました。楕円曲線を使うことと、鍵ペアを使い捨てにすることも、別の改善でした。

⑧ではOpenSSLで実際の接続を観察し、⑨では、その順序の意味を掘り下げました。今回のTLS 1.3では、使い捨ての鍵で共有秘密を作り、用途と送信方向ごとの鍵を導出します。そして、証明書の信頼の経路を確かめ、署名で、その秘密鍵を持つ相手が今回の接続に参加していることを確認します。最後にやり取りを照合し、HTTPのデータを保護して送ります。

⑩では、証明書の検証エラーやC#からも、その仕事の境目を見ました。以前のIPsecのログや調査用スクリプトにも、方式を合わせ、秘密を共有し、相手を確かめるという共通の仕事が現れていました。

中間証明書が足りない。信頼の起点がない。ホスト名が違う。対応する方式が合わない。

エラーが出たときにも、全部を「SSLが駄目」とまとめず、どの手続きで止まったのかを考えられるようになります。

普段はライブラリに任せている接続でも、分からないところを、実験できるところまで分解してみる。OpenSSLのログを見て、証明書を取り出し、C#から境目を覗くことで、一回のWebアクセスの裏側にも、追いかけられる手順が見えてきました。

このシリーズはいったんここまでです。

参考資料

  1. Microsoft Learn — SslStream.AuthenticateAsClientAsync:TLSのクライアント側認証。
  2. Microsoft Learn — SslClientAuthenticationOptions.TargetHost:接続先の名前と検証。
  3. Microsoft Learn — TLS/SSL best practices:プロトコルの選択と証明書の検証方針。
  4. Microsoft Learn — SslStream.NegotiatedCipherSuite:接続で決まった暗号スイート。
  5. RFC 2409 — The Internet Key Exchange (IKE):Main Mode、Quick Mode、PSK認証、用途・方向ごとの鍵導出。
  6. ike-scan — The IKE Scanner:カスタムtransformとMain Mode。同梱マニュアルのDebian掲載版:--transと-M。
  7. strongSwan — Algorithm Proposals:提案する暗号方式、整合性確認、PRF、DHグループの表記。

返信を残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です

CAPTCHA