Ruby で ECH のサーバテストツールを実装した話

これは、[Ruby Advent Calendar 2024](https://qiita.com/advent-calendar/2024/ruby) の 16 日目の記事です。


## はじめに

本記事では、Ruby で ECH のサーバテストツールを実装した話について書きます。


## ECH の概要

2024 年 12 月現在、IETF の TLS WG において ECH (Encrypted Client Hello) という TLS 拡張が議論されています。

https://datatracker.ietf.org/doc/draft-ietf-tls-esni/

TLS は、ハンドシェイクメッセージの Client Hello に通信先のホスト名を記載します。このメッセージは平文で送信されます。第三者は、Client Hello を観測することでクライアントがアクセスしている Web サイトを特定できます。ECH は、Client Hello を暗号化することで第三者から読み取れないようにする拡張です。


## ECH のテストツール `echspec`

ECH は新しい TLS 拡張であり、さまざまなプログラミング言語で実装が進んでいます。新しい仕様において、異なる実装間の動作や互換性を確認することは非常に重要です。そこで、サーバ実装が仕様に沿った動作をするかどうかをテストするツール `echspec` を実装することにしました。

https://github.com/thekuwayama/echspec

`echspec` は TLS 1.3 のクライアントとして動作し、サーバとハンドシェイクを試みます。このハンドシェイクの過程で、サーバが ECH の仕様に沿った実装になっているかどうかをテストします。

例えば、ECH のドラフトには以下の記述があります。

> First it parses EncodedClientHelloInner, interpreting all bytes after client\_hello as padding. If any padding byte is non-zero, the server MUST abort the connection with an "illegal\_parameter" alert.

[https://datatracker.ietf.org/doc/html/draft-ietf-tls-esni-22#section-5.1-9](https://datatracker.ietf.org/doc/html/draft-ietf-tls-esni-22#section-5.1-9)

詳細は省きますが、クライアントから送信されるメッセージの 0 パディング部分が 0 以外でパディングされていた場合、サーバは `illegal_parameter` アラートを返してハンドシェイクを中止する必要があります (MUST) 。`echspec` は、この部分を 0 以外でパディングしてサーバに送信し `illegal_parameter` アラートが返送されるかどうかをテストします。`echspec` は、このような仕様に基づくサーバの振る舞いをテストケースとして検証します。


## `echspec` の実行例

下記は実行例です。

```shell-session
$ echspec research.cloudflare.com
TLS Encrypted Client Hello Server
        ✔ MUST implement the following HPKE cipher suite: KEM: DHKEM(X25519, HKDF-SHA256), KDF: HKDF-SHA256 and AEAD: AES-128-GCM. [9]
        ✔ MUST abort with an "illegal_parameter" alert, if EncodedClientHelloInner is padded with non-zero values. [5.1-9]
        ✔ MUST abort with an "illegal_parameter" alert, if any referenced extension is missing in ClientHelloOuter. [5.1-10]
        ✔ MUST abort with an "illegal_parameter" alert, if any extension is referenced in OuterExtensions more than once. [5.1-10]
        ✔ MUST abort with an "illegal_parameter" alert, if "encrypted_client_hello" is referenced in OuterExtensions. [5.1-10]
        ✔ MUST abort with an "illegal_parameter" alert, if the extensions in ClientHelloOuter corresponding to those in OuterExtensions do not occur in the same order. [5.1-10]
        ✔ MUST abort with an "illegal_parameter" alert, if ECHClientHello.type is not a valid ECHClientHelloType in ClientHelloInner. [7-5]
        x MUST abort with an "illegal_parameter" alert, if ECHClientHello.type is not a valid ECHClientHelloType in ClientHelloOuter. [7-5]
        ✔ MUST abort with an "illegal_parameter" alert, if ClientHelloInner offers TLS 1.2 or below. [7.1-11]
        ✔ MUST include the "encrypted_client_hello" extension in its EncryptedExtensions with the "retry_configs" field set to one or more ECHConfig. [7.1-14.2.1]
        ✔ MUST abort with a "missing_extension" alert, if 2nd ClientHelloOuter does not contains the "encrypted_client_hello" extension. [7.1.1-2]
        ✔ MUST abort with an "illegal_parameter" alert, if 2nd ClientHelloOuter "encrypted_client_hello" enc is empty. [7.1.1-2]
        ✔ MUST abort with a "decrypt_error" alert, if fails to decrypt 2nd ClientHelloOuter. [7.1.1-5]

Failures:

        1) MUST abort with an "illegal_parameter" alert, if ECHClientHello.type is not a valid ECHClientHelloType in ClientHelloOuter. [7-5]
                did not send expected alert: illegal_parameter

1 failure
```

ECH は、Client Hello を暗号化します。そのため、Wireshark を用いてもクライアントが送信した Client Hello を読むことは難しいです。2024 年 12 月現在、ECH を復号するための SSLKEYLOGFILE 拡張はドラフト段階です。

https://datatracker.ietf.org/doc/draft-rosomakho-tls-ech-keylogfile/

`echspec` は、失敗したテストケースのハンドシェイクメッセージやアラートメッセージを JSON フォーマットで出力できます。下記のように、`jq` を使用してテスト結果であるメッセージのスタックを確認できます。

<details>

```shell-session
$ echspec research.cloudflare.com -v 2>&1 > /dev/null |  jq .
{
  "Alert": {
    "level": "0x02",
    "description": "0x32"
  },
  "ClientHello": {
    "msg_type": "0x01",
    "legacy_version": "0x0303",
    "random": "0x07091fa0eec43ccc4158b1514998092b4dda0303b238ad51867d355651aa1377",
    "legacy_session_id": "0x1679fc195436817295f55b4bba927826d7f26c0ac6b936d2ea69692a3c00f418",
    "cipher_suites": [
      "0x1302",
      "0x1303",
      "0x1301"
    ],
    "legacy_compression_methods": [
      "0x00"
    ],
    "extensions": {
      "0x0000": {
        "extension_type": "0x0000",
        "server_name": "0x636c6f7564666c6172652d6563682e636f6d"
      },
      "0x002b": {
        "extension_type": "0x002b",
        "msg_type": "0x01",
        "versions": [
          "0x0304"
        ]
      },
      "0x000d": {
        "extension_type": "0x000d",
        "supported_signature_algorithms": [
          "0x0403",
          "0x0503",
          "0x0603",
          "0x0804",
          "0x0805",
          "0x0806",
          "0x0401",
          "0x0501",
          "0x0601"
        ]
      },
      "0x000a": {
        "extension_type": "0x000a",
        "named_group_list": [
          "0x0017",
          "0x0018",
          "0x0019"
        ]
      },
      "0x0033": {
        "extension_type": "0x0033",
        "msg_type": "0x01",
        "key_share_entry": [
          {
            "group": "0x0017",
            "key_exchange": "0x04e08b2ff2039880820a03a9706661ca6351bb1b4f03befb011478d18a84acee6caf99bcdbc15434038ccbeb2eac727dc0e223c173d0ae30510679131de0a28937"
          },
          {
            "group": "0x0018",
            "key_exchange": "0x04646da67d01eb822c3438f73c23cc1f110d327798ed494b0f3e80f0f610da9e23ee7c40ba0416e58b6ae7b07c144aed35d81aead168a24488f028894d2fad3507deb22fdbff1fadf2a52e99e44596315bc43d99d1bd14be334e6de522282e86b3"
          },
          {
            "group": "0x0019",
            "key_exchange": "0x04012e6c12fc453a168987c0188b328c9e3c0da51761b5f7c2dc2a91037ca80771d5b5f434c9070bb3103e966fb0528409152b5ab65c9bb4ac2d21801dd4c70cd88028003a5faef2b03632cd956987ed9e1e9e6cb38a94f5c82a97826788d3a8ba0ce9477f82bcfc56adf2bc57d1abdbbd1602b401465f7e344ed682d30d2f6e3d7aa955c9"
          }
        ]
      },
      "0xfe0d": {
        "extension_type": "0xfe0d",
        "type": "0x02",
        "cipher_suite": {
          "kdf_id": {
            "uint16": 1
          },
          "aead_id": {
            "uint16": 1
          }
        },
        "config_id": 8,
        "enc": "0x6a618d214da5991b687feadaec52ae1ab3243fd64a9a225cb63fb98544b1b91e",
        "payload": "0x00e3f0f2d4c86c4530df0a9b2b43d2f7e518f204c0af348fb687d23365d794cd0e88aadf48d2bc238cb8dc8fbc0327fb2013450de909c92a9572f557f61e0cb03c2fc5e197ed4f376b329ec945ae2e5e40cb7fe8dc9ebef1b08d21570f8dd3eded591a786b3902af010840f2fa3fabe3f49303faeca8a72ee0941f62d447a700da3a7f2cfe2faa041cc9601f52eb0d12e157145a58234531b3306a43bf3b63bf7b1d2ed0953bdb875d3763d5837cd71307b8cf2b5427cf3a083275f0d14eea61d513a72b16a572fe9a87f7399e5ee8540927370aae0c3188ec746e3144b60da4ccb2a80e78e485d0a54fb5398f54063bed2bb3e017ce4cdc2a0cd3c8edc646d9a239ffd30359be6323e5f4934fecdbcc5742ebc662ff5b1f2086aa2895475a8ec7bb2398ef8c9878fa64d50e725d5fc5bdc3783b8455d25de3a5ef87881790e0a88ef5d75e8724fbed93630edd417696a917cb48bc14f7cd0bdbcee70923e598236bd462f634387e6cf415ae6fc366e93b661738e8622e32e0de05d7da3551696c53776ca941bb8d7b19f8b4920a9435bc68ea12e7d609beb67ef95ddd35feb8e5891fc9869644942fd259b7bb690ec60551026bed930efd919473340f0560997b104cb69e671a211ce4f575af70b96b"
      }
    }
  },
  "ClientHelloInner": {
    "msg_type": "0x01",
    "legacy_version": "0x0303",
    "random": "0xd344d5fdc653ff8f5183ded8f334c64a40f4e5f894088f0e9ffe33bef096cd68",
    "legacy_session_id": "0x1679fc195436817295f55b4bba927826d7f26c0ac6b936d2ea69692a3c00f418",
    "cipher_suites": [
      "0x1302",
      "0x1303",
      "0x1301"
    ],
    "legacy_compression_methods": [
      "0x00"
    ],
    "extensions": {
      "0x0000": {
        "extension_type": "0x0000",
        "server_name": "0x72657365617263682e636c6f7564666c6172652e636f6d"
      },
      "0x002b": {
        "extension_type": "0x002b",
        "msg_type": "0x01",
        "versions": [
          "0x0304"
        ]
      },
      "0x000d": {
        "extension_type": "0x000d",
        "supported_signature_algorithms": [
          "0x0403",
          "0x0503",
          "0x0603",
          "0x0804",
          "0x0805",
          "0x0806",
          "0x0401",
          "0x0501",
          "0x0601"
        ]
      },
      "0x000a": {
        "extension_type": "0x000a",
        "named_group_list": [
          "0x0017",
          "0x0018",
          "0x0019"
        ]
      },
      "0x0033": {
        "extension_type": "0x0033",
        "msg_type": "0x01",
        "key_share_entry": [
          {
            "group": "0x0017",
            "key_exchange": "0x04e08b2ff2039880820a03a9706661ca6351bb1b4f03befb011478d18a84acee6caf99bcdbc15434038ccbeb2eac727dc0e223c173d0ae30510679131de0a28937"
          },
          {
            "group": "0x0018",
            "key_exchange": "0x04646da67d01eb822c3438f73c23cc1f110d327798ed494b0f3e80f0f610da9e23ee7c40ba0416e58b6ae7b07c144aed35d81aead168a24488f028894d2fad3507deb22fdbff1fadf2a52e99e44596315bc43d99d1bd14be334e6de522282e86b3"
          },
          {
            "group": "0x0019",
            "key_exchange": "0x04012e6c12fc453a168987c0188b328c9e3c0da51761b5f7c2dc2a91037ca80771d5b5f434c9070bb3103e966fb0528409152b5ab65c9bb4ac2d21801dd4c70cd88028003a5faef2b03632cd956987ed9e1e9e6cb38a94f5c82a97826788d3a8ba0ce9477f82bcfc56adf2bc57d1abdbbd1602b401465f7e344ed682d30d2f6e3d7aa955c9"
          }
        ]
      },
      "0xfe0d": {
        "extension_type": "0xfe0d",
        "type": "0x01",
        "cipher_suite": null,
        "config_id": null,
        "enc": null,
        "payload": null
      }
    }
  }
}
```

</details>


## `echspec` の実装について

本章では、`echspec` の実装において特に注目している部分について説明します。
ちなみに、`echspec` は、HTTP/2 の仕様準拠テストツール `h2spec` に大変影響を受けました。

https://github.com/summerwind/h2spec


### HPKE

先ず、ECH は仕様として HPKE (Hybrid Public Key Encryption) という仕組みを用います。

https://datatracker.ietf.org/doc/rfc9180/

ECH を実装するには、HPKE の実装が必要です。Ruby の HPKE 実装 `hpke-rb` が公開されています。

https://github.com/sylph01/hpke-rb

これを利用して、TLS 1.3 自前実装である [`tttls1.3`](https://github.com/thekuwayama/tttls1.3) を ECH に対応させました。


### テストケース

`echspec` は `tttls1.3` をオーバーライドしています。例えば、「メッセージの 0 パディング部分を 0 以外でパディングする」などオーバーライドしています。テストケースごとに、サーバの特定の振る舞いを検証するためにクライアントの動作をオーバーライドして実装しました。

```shell-session
$ tree lib/echspec/spec/
lib/echspec/spec/
├── 5.1-10.rb
├── 5.1-9.rb
├── 7-5.rb
├── 7.1-11.rb
├── 7.1-14.2.1.rb
├── 7.1.1-2.rb
├── 7.1.1-5.rb
└── 9.rb

1 directory, 8 files
```


### パターンマッチによるテスト結果出力

`echspec` では、TLS ハンドシェイクがアラートメッセージによって中止されても、プログラムを停止させません。なぜなら、テストケースによってはサーバから特定のアラートメッセージを受け取ることを期待するためです。`tttls1.3` はアラートメッセージを受け取った際に例外オブジェクトを生成しますが、`echspec` は各テストケースの結果を `EchSpec::Ok` または `EchSpec::Err` クラスで包んで返すようにしています。つまり、テストケースの呼び出し元に例外を返すのではなく、`EchSpec::Ok` または `EchSpec::Err` クラスを返すということです。例えば、サーバから仕様通りのアラートメッセージを受け取った場合は `EchSpec::Ok` を返し、サーバが仕様に反するメッセージを受け取った場合はメッセージスタックなどを `EchSpec::Err` に包んで返します。呼び出し元は、`EchSpec::Ok` または `EchSpec::Err` クラスをパターンマッチしてテスト結果を抽出して出力したり、サマライズしたりします。

下記はテスト結果を出力する部分の実装です。

```ruby
      def print_summarize(result, desc)
        check = "\u2714"
        cross = "\u0078"
        summary = case result
                  in Ok
                    "\t#{check} #{desc}".green
                  in Err
                    "\t#{cross} #{desc}".red
                  end
        puts summary
      end
```


## おわりに

今後テストケースを増やしていきたいと考えています。
ぜひお試しいただき、フィードバックをいただけると嬉しいです。