🔑

GoによるTLS1.3サーバーの実装

2024/09/01に公開

はじめに

TLSプロトコルの理解を目的として、Go言語によりTLS1.3のフルハンドシェイクのサーバー実装を行いました。この記事では、実装の過程で得た学びをまとめたいと思います。Go言語はTLSをOpenSSLではなく自前で実装しています。暗号処理関連のパッケージが整っているため、TLSプロトコルの仕様に沿った自然な実装がしやすい言語だと思います。
実際に書いたコードはgo-tlsで公開しています。また、同内容はスライドとしてLearning-TLS1.3-with-Go-full.pdfでもまとめています。

暗号処理

TLSの内容に入る前にまず暗号処理の概要について見ていきます。次の3つの性質が重要です:

  • 秘匿性 (Confidentiality)
    • 通信内容が通信相手以外から読み取られないこと
  • 完全性 (Integrity)
    • 通信内容が途中で改ざんされていないこと
  • 正真性 (Authenticity)
    • 通信相手が正しい相手であること

これらの性質を実現するために、暗号処理では以下のような技術を組み合わせます。

対称鍵暗号

共通鍵暗号とも呼ばれます。暗号化と復号に同じ鍵を使用する暗号方式です。鍵というのは非常に大きな数です。上記で述べた暗号の性質のうち、秘匿性を実現します。また、共通鍵暗号にはブロック暗号とストリーム暗号があります。

  • ブロック暗号: メッセージを固定長のブロックに分けて暗号化します。ブロックの組み合わせ方にバリエーションがあり、暗号利用モードと呼ばれます。
  • ストリーム暗号: 任意長のメッセージ入力を順次暗号化していく方式です。

アルゴリズム例

  • AES (Advanced Encryption Standard): ブロック暗号のアルゴリズムです。ブロック長は128ビット、暗号鍵は128, 192, 256ビットの3種類が利用可能です。
  • ChaCha20: ストリーム暗号のアルゴリズムです。

共通鍵暗号は、AEAD(Authenticated Encryption with Associated Data)という技術と組み合わせることにより、完全性と正真性も同時に実現することができます。上記の共通暗号アルゴリズムをAEADと組み合わせたものは、それぞれAES-GCM, ChaCha20-Poly1305となり、TLS1.3で使われる暗号アルゴリズムです。

公開鍵暗号

公開鍵暗号は、暗号化と復号で異なる鍵を使う暗号方式です。公開鍵暗号では、秘密鍵と公開鍵の2つの鍵のペアを作成します。秘密鍵は鍵作成者が秘匿管理し、公開鍵は暗号化された通信を行う相手に何らかの方法で渡します。通信相手は、メッセージを公開鍵を使い暗号化して送信します。この暗号文は対応する秘密鍵でしか復号できません。秘密鍵は鍵作成者のみが持っているため、他者には暗号文を復号して読み取ることができません。
公開鍵暗号は暗号化以外に、デジタル署名や(共通鍵の)鍵交換という用途にも使われます。実際、暗号化の用途よりもこれらの用途で使われることが多いと思います。TLSでは、1.2まではRSA暗号によりメッセージの暗号化にも使うことができましたが、前方秘匿性の問題のため1.3では利用できなくなりました。公開鍵暗号では以下の例で示すように、ある種の計算の困難さを利用します。

アルゴリズム例

  • RSA: 広く使われている公開鍵暗号で、非常に大きい素数の積の素因数分解が困難であることを利用した暗号です。
  • ECC (Elliptic Curve Cryptography): 楕円曲線暗号と呼ばれる暗号です。楕円曲線と呼ばれる曲線上での離散対数問題の困難さを利用しています。ECCというのは楕円曲線を使った暗号アルゴリズムの総称で、暗号化、デジタル署名、鍵交換の用途に応じて異なるアルゴリズムがあります。暗号化に使われる楕円曲線暗号には、ECIES(Elliptic Curve Integrated Encryption Scheme)があります。TLSでは暗号化の用途としては用いられません。

一方向ハッシュ関数

一方向ハッシュ関数は、入力メッセージから一定の長さの値を計算する関数です。入力の違いがわずかであっても全く異なる値が出力され、出力から入力を推定するのは計算上困難です。ハッシュ値はダイジェストとも呼ばれます。一方向ハッシュ関数はデジタル署名、MAC、鍵導出、擬似乱数生成機など暗号における様々な用途に使われます。一方向ハッシュ関数の重要な性質として以下の性質があります:

  • 弱衝突耐性: ある入力に対して、同じハッシュ値を生成する異なる入力を見つけるのが困難であること
  • 強衝突耐性: 同じハッシュ値を生成する、異なる2つの入力を見つけるのが困難であること

アルゴリズム例

  • SHA256: 256ビットの値を生成するアルゴリズムで広く使われています。

メッセージ認証コード

メッセージ認証コードは、完全性と正真性を実現する技術です。メッセージと共通鍵を入力としてある値(認証タグ)を計算します。メッセージ認証コードの受け取り手は同様の計算を行います。同じ値が得られれば、メッセージが改ざんされていなく、かつ同じ共通鍵を持つ者により計算されたことがわかります。

アルゴリズム例

  • HMAC(Hash Based Message Authentication Code): メッセージと共通鍵を用いて一方向ハッシュ関数によりハッシュ値を計算するアルゴリズムです。

デジタル署名

デジタル署名も完全性と正真性を実現する技術です。公開鍵暗号技術を使用します。送信者はメッセージのダイジェスト(ハッシュ値)を自身の秘密鍵で署名し、メッセージとともに送ります。ダイジェストを使用することで、長いメッセージに対しても効率的に署名を生成できます。受信者は受け取った署名を送信者の公開鍵で検証し、メッセージから自ら計算したダイジェストと一致するかを確認します。一致すれば、メッセージが通信途中で改ざんされておらず、送信者が秘密鍵の所有者であることに確信を持てます。
デジタル署名では完全性、正真性に加えて否認防止も実現できます。メッセージ認証コード(MAC)では共通鍵を持つ送信者・受信者の両方が認証タグを生成できるため、第三者はどちらが生成したかを判別できません。一方、デジタル署名では署名は秘密鍵の所有者一人のみが行えるので、署名者が後で署名を否認することはできません。ただし、デジタル署名の信頼性は公開鍵の正当性に依存します。そのため、多くの場合、公開鍵基盤(PKI)などの仕組みを使用して公開鍵の信頼性を確保します。

アルゴリズム例

  • RSA: 公開鍵暗号と同じアルゴリズムです。広く使用されていますが、比較的大きな鍵長が必要です。
  • ECDSA: 楕円曲線暗号(ECC)ベースのデジタル署名アルゴリズムです。RSAと比べて小さな鍵長で同等のセキュリティを提供します。

疑似乱数生成器

擬似乱数生成器(Pseudo Random Number Generator, PRNG)は、暗号技術で広く使用される重要な要素です。真の乱数が完全にランダムで予測不可能であるのに対し、PRNGはアルゴリズムによって生成される、統計的にランダムに見える数列を提供します。PRNGは高速にランダムな数を生成できるため、真の乱数の生成が困難で時間がかかる状況で利用されます。暗号用途では、PRNGは統計的ランダム性、予測不可能性、再現不可能性といった特性を持つ必要があります。
PRNGの初期値であるシードには、真の乱数や高エントロピーデータを使用することが望ましく、これにより生成される擬似乱数の安全性が高まります。コンピュータでの真の乱数源(エントロピー源)には、ハードウェアノイズ、ユーザー入力、環境センサーなどが使用されます。

TLSのバージョン

TLSはもともとSSLという名前でスタートし、以下のように推移してきました。

  • 1994年にNetscape社により開発される
  • 同年SSL2.0として最初のバージョンがリリースされる。2011年に廃止。
  • 1995年にSSL3.0がリリースされる。2015年に廃止。
  • IETFがSSL3.0をベースにTLSの開発を開始。
  • 1999年にTLS1.0がリリースされる。2021年に廃止。
  • 2006年にTLS1.1がリリースされる。2021年に廃止。
  • 2008年にTLS1.2がリリースされる。
  • 2018年にTLS1.3がリリースされる。

TLS1.3では、1.2から多くの改良がなされ、セキュリティとパフォーマンスが大幅に改善されています。

  • TLS1.2以前では2-RTT(round trip time)かかっていたフルハンドシェイクが1-RTTに短縮
  • 0-RTTモードが追加された(ただしリプレイ攻撃などのリスクがあり、セキュリティとのトレードオフ有り)
  • 暗号スイートが整理され安全でないアルゴリズムが削除された
  • 共通鍵による暗号化にAEADが必須となった
  • 前方秘匿性を持たないRSAによる鍵交換が廃止され、Diffie-Hellmanベースの鍵交換に限定された

TLS1.3におけるフルハンドシェイク

TLS1.3におけるフルハンドシェイクのシーケンスは、以下の図のようになります。フルハンドシェイクは、事前共有鍵や前回のセッション情報がない場合に、一からTLS接続を確立するプロセスです。影付きの部分(ServerHello後のメッセージ)が暗号化されます。

ハンドシェイクでは、鍵交換アルゴリズムにより、通信の暗号化に使う共通鍵を生成します。ClientHello及びServerHelloメッセージで暗号アルゴリズムについて合意をし、お互いに公開鍵暗号の公開鍵を交換します。相手から共有された公開鍵と、自身の秘密鍵から共有秘密という値を計算し、各種共通鍵の生成に使用します(TLSでは共通鍵は一種類ではなく、用途に応じて複数生成します)。
その後クライアントはサーバーの認証を行います。サーバーは証明書チェーンをCertificateメッセージにより送り、クライアントはチェーンの検証を行います。CertificateVerifyメッセージでは、サーバーが証明書の公開鍵に対応する秘密鍵を所有していることを検証します。秘密鍵で作成したデジタル署名をクライアントに送り、クライアントはCertificateメッセージで受け取った公開鍵を使用して署名の検証を行います。
最後にFinishedメッセージにより、これまでやり取りしたメッセージの完全性をHMACによりお互いに検証します。これによりハンドシェイクが完了し、その後暗号化されたアプリケーションデータのやり取りが行われます。

以降では、ハンドシェイクの各ステップについて、Goの実装とともに詳しく見ていきます。

GoによるTLS1.3ハンドシェイクの実装

TLSレコードプロトコル

最初にTLSにおけるメッセージの構造を見ていきます。これはTLS Record Protocolにより定められて、以下のようなレイアウトをしています。最初の5バイトがヘッダーで、残りがペイロードです。

  • ContentType: 1バイトでサブプロトコルを表します。以下のようなものがあります。
    • hanshake(0x16): ハンドシェイクのメッセージ
    • application_data(0x17): 暗号化されたデータのメッセージです
    • alert(0x15): 通信の切断やエラーを表すメッセージです
  • LegacyVersion: 2バイトのデータで、TLS1.2を表す0x0303またはTLS1.0を表す0x0301が使われます。TLS1.3の値0x0304はここでは使用されません。これはネットワーク機器の後方互換性によるものです。
  • Length: 後続のペイロード長を表現する2バイトのデータです。
  • Payload: データのペイロードで、平文(ClientHelloやServerHello)または暗号文です。

暗号化されたメッセージは、Application Dataプロトコルのメッセージとして表現され、本来のContentTypeは、暗号化前のメッセージ構造の中に含まれます。以下の図はそれを模式的に表したものです。したがって、ハンドシェイクのメッセージであっても、ServerHello後の暗号化されメッセージは、外形的にはApplication Dataのメッセージに見えます。

以下はGoによる、TLSレコードの受信処理のコードです。ここで例示するコードでは、TLSプロトコルの処理に着目するため、エラーハンドリング等を省略しています。
TCPで受信を待ち受け、受信したメッセージのヘッダー5バイトをパースしてペイロード長を取得し、ペイロードを読み込んでいます。読み込んだデータをTLSレコードのデータ構造として表現し、以降でContentTypeに応じて処理を進めます。また、ネットワークバイトオーダ(ビッグエンディアン)でデータをパースしています。

import (
    "encoding/binary"
    "io"
    "net"
)

type (
    ContentType     uint8 // for Record protocol
    ProtocolVersion uint16

    TLSRecord struct {
        ContentType         ContentType
        LegacyRecordVersion ProtocolVersion
        Length              uint16
        Fragment            []byte
    }
)

func TLSServer() {
    listener, _ := net.Listen("tcp", ":443")
    for {
        conn, _ := listener.Accept()
        go func(conn net.Conn) {
            // Read TLS Record header
            tlsHeaderBuffer := make([]byte, 5)
            conn.Read(tlsHeaderBuffer)
            length := binary.BigEndian.Uint16(tlsHeaderBuffer[3:5])
            // Read TLS Record payload
            payloadBuffer := make([]byte, length)
            io.ReadFull(conn, payloadBuffer)

            tlsRecord := &TLSRecord{
                ContentType:         ContentType(tlsHeaderBuffer[0]),
                LegacyRecordVersion: ProtocolVersion(binary.BigEndian.Uint16(tlsHeaderBuffer[1:3])),
                Length:              length,
                Fragment:            payloadBuffer,
            }
            switch tlsRecord.ContentType {
            // Handle Record (handshake, application_data, etc)
            }
        }(conn)
    }
}

ClientHello

ハンドシェイクの最初のメッセージはClientHelloです。このメッセージにより、クライアントはサポートしている暗号アルゴリズムや、優先して使用したいアルゴリズムをサーバーに伝えます。

右側の図にハンドシェイクメッセージ(TLSレコードのペイロード)のレイアウトを示しました。最初の1バイト目はハンドシェイクメッセージの種別を表します。値のと対応は以下の通りで、ClientHelloを表す0x01になります。

type HandshakeType uint8

const (
    ClientHello         HandshakeType = 0x01
    ServerHello         HandshakeType = 0x02
    EncryptedExtensions HandshakeType = 0x08
    Certificate         HandshakeType = 0x0b
    CertificateVerify   HandshakeType = 0x0f
    Finished            HandshakeType = 0x14
)

次の3バイトは、ハンドシェイクメッセージの長さを表します。メッセージはClientHelloでは、以下のような構造として定義されています(RFCではこのような記法によりデータ構造が表現されています)。

uint16 ProtocolVersion;        
opaque Random[32];             
uint8 CipherSuite[2]; 

struct {                       
    ProtocolVersion legacy_version = 0x0303; /* TLS1.2 */
    Random random; /* 32 bytes random */        
    opaque legacy_session_id<0..32>; /* for compatibility */
    CipherSuite cipher_suites<2..2^16-2>;
    opaque legacy_compression_methods<1..2^8-1>; /* zero */
    Extension extensions<8..2^16-1>;
} ClientHello;

後方互換性のフィールドなども含まれますが、特に重要なのはcipher_suitesextensionsです。cipher_suitesではクライアントがサポートしている暗号スイートが提示されます。extensionsはTLS拡張と呼ばれるもので、TLSでは拡張を通して後から機能を追加できるように設計されています。extensionsを通して、サポートしている楕円曲線、デジタル署名のアルゴリズム、優先的に利用したい鍵交換アルゴリズムとそのパラメーターなどがサーバーに共有されます。

具体的なサンプルデータを通して、以下で詳しく見ていきます。クライアントにはOpenSSLを使用しています。go-tlsのコードで試す場合は、以下のようにサーバーを起動します。

git clone https://github.com/shu-yusa/go-tls
cd go-tls
make server-crt # 自己署名証明書の作成
make start # TLSサーバー起動

次に、別のターミナルで以下のようなopenssl s_clientコマンドにより、サーバーとハンドシェイクを行います。

openssl s_client -debug -connect localhost:443 -CAfile server.crt -tls1_3 -noservername -crlf -curves x25519:secp256r1 -sigalgs ECDSA+SHA256:ed25519 -msg -keylogfile keylog.txt

オプションが多数ついていますが、おおよその意味は以下のとおりです:

  • -debug: 送受信メッセージの内容等を出力するデバッグオプション
  • -connect localhost:443: localhostの443ポートへの接続
  • -CAfile server.crt: 作成した自己署名証明書を信頼できる証明書として扱う
  • -tls1_3: TLS1.3を利用
  • -noservername: SNI (Server Name Indication)拡張を含めない
  • -crlf: 送信メッセージの改行をCR LFにするオプション。HTTPのテスト用
  • -curves x25519:secp256r1: クライアントが使用する楕円曲線を指定(X25519とsecp256r1を優先)
  • -sigalgs ECDSA+SHA256:ed25519: 使用するデジタル署名のアルゴリズムを指定(ECDSA+SHA256とed25519を優先)
  • -msg: デバッグ用オプション。送受信メッセージの内容が少しわかりやすくなります
  • -keylogfile keylog.txt: 鍵交換プロセスで生成したシークレットをファイルに出力(デバッグ用)

コマンドを実行すると、次のような183バイトのデータが出力されます。ClientHelloメッセージの実際のバイト列です。

write to 0x60000202c000 [0x12300e600] (183 bytes => 183 (0xB7))
16 03 01 00 b2 01 00 00-ae 03 03 b5 28 8a e8 56
d7 18 3f fe cc 3d df ba-47 81 e4 dd f5 83 34 af
94 84 1d ba 89 26 e5 c6-cb 89 39 20 20 63 4d 59
fc a8 70 8e db fa 7c 5a-a4 e7 5a 99 92 58 9f 11
13 3e d8 12 cb db e7 40-12 b1 ed 92 00 06 13 02
13 03 13 01 01 00 00 5f-00 0b 00 04 03 00 01 02
00 0a 00 06 00 04 00 1d-00 17 00 23 00 00 00 16
00 00 00 17 00 00 00 0d-00 06 00 04 08 07 04 03
00 2b 00 03 02 03 04 00-2d 00 02 01 01 00 33 00
26 00 24 00 1d 00 20 59-4f 1b 75 88 f5 fe b8 c9
7d 7d eb 01 c6 df 05 58-a4 66 a0 b8 6b 87 ce ba
35 4b dd 33 46 b4 49

最初の5バイト16 03 01 00 b2はTLSレコードのヘッダーです。

値の対応は以下の通りで、ContentTypeはHandshakeを表す0x16, Legacy VersionにTLS 1.0を表す0x0301が送信されているのがわかります。

type (
    ContentType     uint8
    ProtocolVersion uint16
)

const (
    InvalidRecord          ContentType = 0x00
    ChangeCipherSpecRecord ContentType = 0x14
    AlertRecord            ContentType = 0x15
    HandshakeRecord        ContentType = 0x16
    ApplicationDataRecord  ContentType = 0x17

    TLS10 ProtocolVersion = 0x0301
    TLS11 ProtocolVersion = 0x0302
    TLS12 ProtocolVersion = 0x0303
    TLS13 ProtocolVersion = 0x0304
)

0x00b2=16×11+2=178バイトはペイロード長です。
ハンドシェイクメッセージの最初の4バイト01 00 00 aeはハンドシェイクの種別(ClientHello)とメッセージ長(0x0000ae=16×10+15=174バイト)を表します。

残りの部分がハンドシェイクメッセージのペイロードです。異なるフィールドを視覚的に区別するために色を付けています。

ClientHelloのデータ構造と比較して見ていきます。

type (
    ProtocolVersion uint16     
    CipherSuite     uint16     
    ExtensionType   uint16
    Extension       struct {   
        ExtensionType ExtensionType     
        Length        uint16   
        Data          []byte   
    }
    ClientHelloMessage struct {
        LegacyVersion           ProtocolVersion // 0x0303 (TLS1.2)
        Random                  []byte          // 32 bytes random
        LegacySessionID         []byte          // 0-32 bytes
        CipherSuites            []CipherSuite   // 2..2^16-2 bytes
        LegacyCompressionMethod []byte          // 1..2^8-1 bytes
        Extensions              []Extension     // 8..2^16-1 bytes
    }
)

最初の2バイト03 03legacy_versionでTLS1.2を表す値で固定です。後方互換性によるものです。
次の32バイトb5 28 8a e8 56 d7 18 3f fe cc 3d df ba-47 81 e4 dd f5 83 34 af 94 84 1d ba 89 26 e5 c6-cb 89 39はクライアントが生成するランダム値で、個々のハンドシェイクにつき、一意な値を生成することが求められます。
次はlegacy_session_idという、後方互換性のための最大32バイトまでの可変長フィールドです。一般的な規則として、可変長フィールドに対しては、最初に最大長に合わせた長さを表現するバイトが来ます。32バイトまでの長さは表現するには1バイトで十分なため、最初の0x20=32バイトがフィールド長を表し、その後に続く32バイト20 20 63 4d 59 fc a8 70 8e db fa 7c 5a-a4 e7 5a 99 92 58 9f 11 13 3e d8 12 cb db e7 40-12 b1 ed 92が、legacy_session_idの値です。なお、サーバーはServerHelloメッセージで同じlegacy_session_idの値を返さなければなりません。
次のフィールドは重要で、クライアントがサポートする暗号スイートが指定されています。可変長フィールドで最大長が2^16-2バイトであるため、最初の2バイト0x0006=6バイトが長さになります。TLS1.3で利用可能な暗号スイートは以下の通りです。

type CipherSuite uint16

const (
    // CipherSuites
    TLS_AES_128_GCM_SHA256       CipherSuite = 0x1301
    TLS_AES_256_GCM_SHA384       CipherSuite = 0x1302
    TLS_CHACHA20_POLY1305_SHA256 CipherSuite = 0x1303
    TLS_AES_128_CCM_SHA256       CipherSuite = 0x1304
    TLS_AES_128_CCM_8_SHA256     CipherSuite = 0x1305
)

共通鍵暗号はブロック暗号のAES、またはストリーム暗号のChaCha20です。暗号のセクションでも述べた通りAEADが必須となっています。上記の例13 02 13 03 13 01では最初の3つの暗号スイートが指定されています。
次の01 00はメッセージの圧縮についての指定ですが、これは圧縮しないことを表す0x00で固定です(0x01は長さです)。メッセージを圧縮すると、圧縮率が平文へのヒントとなることがわかっているため圧縮は行われません。
残りの部分はextensionsフィールドで、TLS拡張を表します。最初の2バイト0x005f=105バイトは拡張部分の長さを表しています。

各拡張は、最初の2バイトが拡張の種別を表しています。この例に登場している拡張は以下の通りです。

type ExtensionType uint16      
                               
const (
    SupportedPointFormatsExtensionType ExtensionType = 0x000b // 11
    SupportedGroupsExtensionType       ExtensionType = 0x000a // 10
    SessionTicketExtensionType         ExtensionType = 0x0023 // 35
    EncryptThenMacExtensionType        ExtensionType = 0x0016 // 22
    ExtendedMasterSecretExtensionType  ExtensionType = 0x0017 // 23
    SignatureAlgorithmsExtensionType   ExtensionType = 0x000d // 13
    SupportedVersionsExtensionType     ExtensionType = 0x002b // 43
    PSKKeyExchangeModesExtensionType   ExtensionType = 0x002d // 45
    KeyShareExtensionType              ExtensionType = 0x0033 // 51
)

ここでは重要な拡張について見ていきます。

Supported Groups Extension

0x000aから始まる00 0a 00 06 00 04 00 1d-00 17はSupported Groups Extensionで、楕円曲線暗号で使う名前付き曲線が指定されています。データ構造は以下の通りで、0x0006=6バイトが拡張の長さを表し、次の0x0004=4バイトは名前付き曲線のリストの長さを表します。各名前付き曲線は2バイトで表現されるため、2つの名前付き曲線x25519secp256r1が指定されていることがわかります。

type (
    NamedGroup              uint16
    SupportedGroupExtension struct {
        Length         uint16
        NamedGroupList []NamedGroup
    }
)

const (
    secp256r1 NamedGroup = 0x0017
    secp384r1 NamedGroup = 0x0018
    secp521r1 NamedGroup = 0x0019
    x25519    NamedGroup = 0x001d
)

Signature Algorithms Extension

0x000dから始まる00 0d-00 06 00 04 08 07 04 03はSignature Algorithms Extensionで、デジタル署名のアルゴリズムの指定です。データ構造は以下の通りで、ED25519ECDSA_SECP256R1_SHA256が指定されています。

type (
    SignatureScheme              uint16
    SignatureAlgorithmsExtension struct {
        Length                       uint16
        SupportedSignatureAlgorithms []SignatureScheme
    }
)

const (
    ED25519                SignatureScheme = 0x0807
    ECDSA_SECP256R1_SHA256 SignatureScheme = 0x0403
    ECDSA_SECP384R1_SHA384 SignatureScheme = 0x0503
    ECDSA_SECP521R1_SHA512 SignatureScheme = 0x0603
)

Supported Versions Extension

0x002bから始まる00 2b 00 03 02 03 04は、TLSのバージョンを指定する拡張です。0x0003=3バイトがTLS拡張の長さ、0x02=2バイトがTLSバージョンのリストの長さを表し、TLS1.3を表す0x0304のみが指定されています。以下にデータ構造を示します。

type (
    ProtocolVersion                  uint16
    ClientSupportedVersionsExtension struct {
        SelectedVersions []ProtocolVersion
    }
)

const (
    TLS10 ProtocolVersion = 0x0301
    TLS11 ProtocolVersion = 0x0302
    TLS12 ProtocolVersion = 0x0303
    TLS13 ProtocolVersion = 0x0304
)

Key Share Extension

0x0033から最後の部分までの00 33 00 26 00 24 00 1d 00 20 59-4f 1b 75 88 f5 fe b8 c9 7d 7d eb 01 c6 df 05 58-a4 66 a0 b8 6b 87 ce ba 35 4b dd 33 46 b4 49はKeyShare Extensionで、ECDHE鍵交換で使用したい楕円曲線の名前付き曲線と、クライアントからサーバーに共有する公開鍵が含まれます。拡張の長さは0x0026=38バイトで、データ構造は以下の通りです。

type (
    NamedGroup    uint16
    KeyShareEntry struct {
        Group           NamedGroup
        Length          uint16
        KeyExchangeData []byte
    }
    KeyShareExtension struct {
        Length       uint16
        ClientShares []KeyShareEntry
    }
)

const (
    secp256r1 NamedGroup = 0x0017
    secp384r1 NamedGroup = 0x0018
    secp521r1 NamedGroup = 0x0019
    x25519    NamedGroup = 0x001d
)

共有データのリストの長さは0x0024=36バイトです。最初のリストの要素(KeyShareEntry)を見ると、0x001dから名前付き曲線はx25519です。公開鍵の長さは0x0020=32バイトで、残りのバイト列が公開鍵です。サーバーに共有されるECDHEパラメータと公開鍵は1組であることがわかります。

以上でClientHelloの解析は終了で、クライアントは以下の内容をサーバーに送っていることがわかりました。

  • TLSバージョン: TLS1.3
  • 暗号スイート
    • TLS_AES_256_GCM_SHA384
    • TLS_CHACHA20_POLY1305_SHA256
    • TLS_AES_128_GCM_SHA256
  • 楕円曲線暗号の名前付き曲線
    • x25519
    • secp256r1
  • ECDHE鍵交換のパラメータと公開鍵
    • 名前付き曲線: x25519
    • 公開鍵: 594f1b7588f5feb8c97d7deb01c6df0558a466a0b86b87ceba354bdd3346b449
  • デジタル署名アルゴリズム
    • ed25519
    • ecdsa_secp256r1_sha256

これらはopenssl s_clientのパラメータ通りになっているのが確認できます。これを受けてサーバーは使用するアルゴリズムを選択し、ServerHelloで応答します。ここでは以下のアルゴリズムを選択することにして次へ進みましょう。なお、クライアントから提示された暗号スイートにサーバーがサポートしているものがない場合や、鍵交換で異なる楕円曲線を使いたい場合などは、サーバーはHelloRetryRequestメッセージを通してクライアントに要求を伝えることができます(一回のみ可能です)。

  • TLSバージョン: TLS1.3
  • 暗号スイート
    • TLS_AES_128_GCM_SHA256
  • 楕円曲線暗号の名前付き曲線
    • x25519
  • ECDHE鍵交換のパラメータと公開鍵
    • 名前付き曲線: x25519
    • 公開鍵: 594f1b7588f5feb8c97d7deb01c6df0558a466a0b86b87ceba354bdd3346b449
  • デジタル署名アルゴリズム
    • ecdsa_secp256r1_sha256

ClientHelloメッセージを解析して、ここまで定義したデータ構造を組み立てるコードは以下のようになります(今回関心のあるTLS拡張以外の拡張は処理を省略しています)。

import (
    "encoding/binary"
)

func NewClientHello(clientHelloBuffer []byte) ClientHelloMessage {
    legacyVersion := ProtocolVersion(binary.BigEndian.Uint16(clientHelloBuffer[0:2])) // 2 bytes
    random := clientHelloBuffer[2:34]                                                 // 32 bytes
    legacySessionIDLength := uint8(clientHelloBuffer[34])                             // 1 byte
    legacySessionID := clientHelloBuffer[35 : 35+legacySessionIDLength]
    cipherSuiteLength := binary.BigEndian.Uint16(clientHelloBuffer[35+legacySessionIDLength : 35+legacySessionIDLength+2]) // 2 bytes
    cipherSuites := []CipherSuite{}
    for i := 0; i < int(cipherSuiteLength); i += 2 {
        cipherSuite := binary.BigEndian.Uint16(clientHelloBuffer[35+int(legacySessionIDLength)+2+i : 35+int(legacySessionIDLength)+2+i+2])
        cipherSuites = append(cipherSuites, CipherSuite(cipherSuite))
    }
    legacyCompressionMethodLength := uint8(clientHelloBuffer[35+int(legacySessionIDLength)+2+int(cipherSuiteLength)])
    legacyCompressionOffset := 35 + int(legacySessionIDLength) + 2 + int(cipherSuiteLength) + 1
    legacyCompressionMethod := clientHelloBuffer[legacyCompressionOffset : legacyCompressionOffset+int(legacyCompressionMethodLength)]

    // Extensions
    extensionOffset := 35 + int(legacySessionIDLength) + 2 + int(cipherSuiteLength) + 1 + int(legacyCompressionMethodLength)
    extensionLength := binary.BigEndian.Uint16(clientHelloBuffer[extensionOffset : extensionOffset+2])
    extensionBuffer := clientHelloBuffer[extensionOffset+2 : extensionOffset+2+int(extensionLength)]
    var extensions []Extension
    cursor := 0
    for cursor < int(extensionLength) {
        length := binary.BigEndian.Uint16(extensionBuffer[cursor+2 : cursor+4])
        extensions = append(extensions, Extension{
            ExtensionType: ExtensionType(binary.BigEndian.Uint16(extensionBuffer[cursor : cursor+2])),
            Length:        length,
            Data:          extensionBuffer[cursor+4 : cursor+4+int(length)],
        })
        // 4 bytes for Type and Length
        cursor += 4 + int(length)
    }
    return ClientHelloMessage{
        LegacyVersion:           legacyVersion,
        Random:                  random,
        LegacySessionID:         legacySessionID,
        CipherSuites:            cipherSuites,
        LegacyCompressionMethod: legacyCompressionMethod,
        Extensions:              extensions,
    }
}

func (ch ClientHelloMessage) ParseExtensions() map[ExtensionType]interface{} {
    var extensionMap = make(map[ExtensionType]interface{})
    for _, extension := range ch.Extensions {
        switch extension.ExtensionType {
        case SupportedPointFormatsExtensionType:
            length := uint8(extension.Data[0])
            var ECPointFormats []uint8
            for i := 0; i < int(length); i++ {
                ECPointFormat := uint8(extension.Data[1+i])
                ECPointFormats = append(ECPointFormats, ECPointFormat)
            }
            extensionMap[SupportedPointFormatsExtensionType] = SupportedPointFormatsExtension{
                Length:         length,
                ECPointFormats: ECPointFormats,
            }
        case SupportedGroupsExtensionType:
            length := binary.BigEndian.Uint16(extension.Data[0:2])
            var NamedGroupList []NamedGroup
            for i := 0; i < int(length); i += 2 {
                namedGroup := binary.BigEndian.Uint16(extension.Data[2+i : 2+i+2])
                NamedGroupList = append(NamedGroupList, NamedGroup(namedGroup))
            }
            extensionMap[SupportedGroupsExtensionType] = SupportedGroupExtension{
                Length:         length,
                NamedGroupList: NamedGroupList,
            }
        case SessionTicketExtensionType:
        case EncryptThenMacExtensionType:
        case ExtendedMasterSecretExtensionType:
        case SignatureAlgorithmsExtensionType:
            length := binary.BigEndian.Uint16(extension.Data[0:2])
            var signatureAlgorithms []SignatureScheme
            for i := 0; i < int(length); i += 2 {
                signatureScheme := binary.BigEndian.Uint16(extension.Data[2+i : 2+i+2])
                signatureAlgorithms = append(signatureAlgorithms, SignatureScheme(signatureScheme))
            }
            extensionMap[SignatureAlgorithmsExtensionType] = SignatureAlgorithmsExtension{
                Length:                       length,
                SupportedSignatureAlgorithms: signatureAlgorithms,
            }
        case SupportedVersionsExtensionType:
            length := uint8(extension.Data[0])
            versions := []ProtocolVersion{}
            for i := 0; i < int(length); i += 2 {
                version := binary.BigEndian.Uint16(extension.Data[1+i : 1+i+2])
                versions = append(versions, ProtocolVersion(version))
            }
            extensionMap[SupportedVersionsExtensionType] = ClientSupportedVersionsExtension{
                SelectedVersions: versions,
            }
        case PSKKeyExchangeModesExtensionType:
            length := uint8(extension.Data[0])
            keModes := []PSKKeyExchangeMode{}
            for i := 0; i < int(length); i++ {
                keMode := PSKKeyExchangeMode(extension.Data[1+i])
                keModes = append(keModes, PSKKeyExchangeMode(keMode))
            }
            extensionMap[PSKKeyExchangeModesExtensionType] = PSKKeyExchangeModesExtension{
                Length:  length,
                KEModes: keModes,
            }
        case KeyShareExtensionType:
            length := binary.BigEndian.Uint16(extension.Data[0:2])
            var clientShares []KeyShareEntry
            keyShareCursor := 2
            for keyShareCursor < int(length) {
                group := binary.BigEndian.Uint16(extension.Data[keyShareCursor : keyShareCursor+2])
                keyExchangeDataLength := binary.BigEndian.Uint16(extension.Data[keyShareCursor+2 : keyShareCursor+4])
                clientShare := KeyShareEntry{
                    Group:           NamedGroup(group),
                    Length:          keyExchangeDataLength,
                    KeyExchangeData: extension.Data[keyShareCursor+4 : keyShareCursor+4+int(keyExchangeDataLength)],
                }
                clientShares = append(clientShares, clientShare)
                keyShareCursor += 4 + int(keyExchangeDataLength)
            }
            extensionMap[KeyShareExtensionType] = KeyShareExtension{
                Length:       length,
                ClientShares: clientShares,
            }
        default:
        }
    }
    return extensionMap
}

ServerHello

ServerHelloはClientHelloに対する応答で、クライアントの希望する暗号スイートや鍵交換パラメータに合意し、自身で生成したECDHE用の公開鍵を共有します。

ECDHE鍵交換アルゴリズムのフローは以下のようになります。

  1. クライアントとサーバーは使用する楕円曲線について合意する(x25519など)
  2. クライアントとサーバーはそれぞれ合意した楕円曲線に基づき、一時的な(ephemeral)秘密鍵-公開鍵のペアを生成する
  3. お互いに公開鍵を交換する
  4. クライアントとサーバーはそれぞれ自身で生成した秘密鍵と、相手から共有された公開鍵から共有秘密という値を計算する。ECDHEの特性により、クライアントとサーバーで同じ値が生成される
  5. 共有秘密から各種用途に用いる共通鍵を生成する

ECDHE(Ephemeral Elliptic Curve Diffie-Hellman)では、秘密鍵-公開鍵ペアは毎回新たに生成され、使い回されません。それにより、あるセッションでの秘密鍵が漏洩した場合でも、以前の通信で使用した共有秘密は計算できず、過去の通信を解読することはできません。このような性質を前方秘匿性といいます。TLS1.3ではこの前方秘匿性が必須となっています。

サーバーが秘密鍵-公開鍵ペアを作成し、ServerHelloメッセージを構築する処理を見ていきます。

import (
    "crypto/ecdh"
    "crypto/rand"
    "net"
)

func HandleClientHello(conn net.Conn, clientHelloBytes []byte) (*TLSContext, error) {
    // ClientHelloメッセージを解析し、ClientHelloMessage構造体を作成する(TLSレコードヘッダーの4バイトを除いている)
    clientHello := NewClientHello(clientHelloBytes[4:])
    // ClientHelloのTLS拡張を解析し、拡張種別(ExtensionType)をキーとしたマップを作る
    extensions := clientHello.ParseExtensions()
    // KeyShare拡張を取り出す
    keyShareExtension := extensions[KeyShareExtensionType].(KeyShareExtension)
    keySharedEntry := keyShareExtension.ClientShares[0]
    // クライアントから提示された楕円曲線X25519を、ecdhパッケージから使用する(選択処理のコードは省略)
    selectedCurve := ecdh.X25519()
    // クライアントから共有されたECDHE公開鍵を取り出す
    clientECDHPublicKey := keySharedEntry.KeyExchangeData
    // サーバーのECDHE秘密鍵を生成する
    ecdhServerPrivateKey, err := selectedCurve.GenerateKey(rand.Reader)
    // 秘密鍵とペアになる公開鍵を生成する
    ecdhServerPublicKey := ecdhServerPrivateKey.PublicKey()

    // クライアントに返答するServerHelloMessage構造体を構築する
    serverHello, err := NewServerHello(ecdhServerPublicKey, keySharedEntry.Group, TLS_AES_128_GCM_SHA256, clientHello.LegacySessionID)
    // 処理が続く
}

前のセクションで解析したClientHelloメッセージのKeyShare拡張から、ECDHE鍵交換で使用する楕円曲線と、クライアントの公開鍵を取り出しています。サーバーでも同じ楕円曲線上で秘密鍵-公開鍵のペアを作成し、ServerHelloメッセージを作成しています。ServerHelloのデータ構造は以下のように定義されています。

struct {
    ProtocolVersion legacy_version = 0x0303;    /* TLS v1.2 */
    Random random;
    opaque legacy_session_id_echo<0..32>;
    CipherSuite cipher_suite;
    uint8 legacy_compression_method = 0;
    Extension extensions<6..2^16-1>;
} ServerHello;

ClientHelloと同様に、legacy_versionはTLS1.2に対応する0x0303で固定です。randomはサーバーが生成するランダム値で、ClientHelloと同様に、TLSセッションの一意性のためのフィールドです。legacy_session_id_echoには、ClientHelloのlegacy_session_idをそのまま返します。cipher_suiteは、ClientHelloで提示された暗号スイートのリストから選択したものを指定します。legacy_compression_methodは圧縮なしのゼロです。extensionsはTLS拡張で、ECDHE鍵交換でクライアントに共有するサーバーの公開鍵は、KeyShare拡張を通して渡します。
次に示すのはGoでのServerHelloメッセージの構築処理です。

type (
    ServerHelloMessage struct {
        LegacyVersion     ProtocolVersion
        RandomBytes       [32]byte
        SessionID         []byte
        CipherSuite       CipherSuite
        CompressionMethod uint8
        Extensions        []Extension
    }
)

func NewServerHello(publicKey *ecdh.PublicKey, namedGroup NamedGroup, cipherSuite CipherSuite, sessionID []byte) (ServerHelloMessage, error) {
    randomData := make([]byte, 32)
    _, err := rand.Read(randomData)
    if err != nil {
        return ServerHelloMessage{}, err
    }

    publicKeyBytes := publicKey.Bytes()
    keyShareExtension := KeyShareExtension{
        Length: 2 + 2 + uint16(len(publicKeyBytes)),
        ClientShares: []KeyShareEntry{
            {
                Group:           namedGroup,
                Length:          uint16(len(publicKeyBytes)),
                KeyExchangeData: publicKeyBytes,
            },
        },
    }

    return ServerHelloMessage{
        LegacyVersion:     TLS12,
        RandomBytes:       [32]byte(randomData),
        SessionID:         sessionID,
        CipherSuite:       cipherSuite,
        CompressionMethod: 0x00,
        Extensions: []Extension{
            {
                ExtensionType: SupportedVersionsExtensionType,
                Length:        2,
                Data:          []byte{byte(TLS13 >> 8), byte(TLS13 & 0xff)}, // 0x0304
            },
            {
                ExtensionType: KeyShareExtensionType,
                Length:        keyShareExtension.Length,
                Data:          keyShareExtension.Bytes(),
            },
        },
    }, nil
}

先ほど述べた通りにServerHelloMessage構造体を作成しています。TLS拡張には、KeyShare拡張に加えて、SupportedVersions拡張によりTLS1.3への合意を示しています。なお、KeyShare拡張の構造体をネットワークバイトオーダーでバイト列に変換するメソッドは、以下のように実装しています(他の構造体でも同様です)。

func (kse KeyShareExtension) Bytes() []byte {
    extension := []byte{}
    for _, clientShare := range kse.ClientShares {
        extension = append([]byte{
            byte(uint8(clientShare.Group >> 8)),
            byte(uint8(clientShare.Group)),
            byte(uint8(clientShare.Length >> 8)),
            byte(uint8(clientShare.Length)),
        }, clientShare.KeyExchangeData...)
    }
    return extension
}

以上でServerHelloメッセージが構築できたので、Handshakeメッセージのヘッダー、TLSレコードのヘッダーを加えてクライアントに送信します。

type (
    HandshakeEncoder interface {
        Bytes() []byte
    }
    Handshake[T HandshakeEncoder] struct {
        MsgType          HandshakeType
        Length           uint32
        HandshakeMessage T
    }
)

func HandleClientHello(conn net.Conn, clientHelloBytes []byte) (*TLSContext, error) {
    // ...
    // 先ほどの処理の続き
    serverHello, err := NewServerHello(ecdhServerPublicKey, keySharedEntry.Group, TLS_AES_128_GCM_SHA256, clientHello.LegacySessionID)
    if err != nil {
        return err
    }

    handshakeServerHelloBytes := Handshake[ServerHelloMessage]{
        MsgType:          ServerHello, // 0x02
        Length:           uint32(len(serverHello.Bytes())),
        HandshakeMessage: serverHello,
    }.Bytes()
    serverHelloTLSRecord := TLSRecord{
        ContentType:         HandshakeRecord, // 0x16
        LegacyRecordVersion: TLS12,           // 0x0303
        Length:              uint16(len(handshakeServerHelloBytes)),
        Fragment:            handshakeServerHelloBytes,
    }
    _, err := conn.Write(serverHelloTLSRecord.Bytes())
    if err != nil {
        return err
    }
    // 処理が続く
}

実際のバイトデータの例は以下のようになります。

16 03 03 00 7a
02 00 00 76 03 03 43 35-7b 97 c4 4e 00 f9 0a fc
7c 27 3b 34 4b 4f 35 c9-8d ef 01 ae 27 0d e4 1e
90 cd 90 dc 68 a2 20 20-63 4d 59 fc a8 70 8e db
fa 7c 5a a4 e7 5a 99 92-58 9f 11 13 3e d8 12 cb
db e7 40 12 b1 ed 92 13-01 00 00 2e 00 2b 00 02
03 04 00 33 00 24 00 1d-00 20 d3 56 e6 58 9f 6c
48 52 53 4d 0e 1c 54 57-c5 30 07 8c 91 b1 79 e2
61 85 4a 9d c4 33 51 9f-28 23

これでTLS通信についてクライアントとサーバー間の合意が確立しました。ECDHE鍵交換で共有したデータをもとに、クライアントとサーバーは同一の共有秘密を計算します。共有秘密から用途に応じて異なる共通鍵を計算します。この計算過程は鍵スケジュールと呼ばれます。

鍵スケジュール

サーバーは、クライアントから共有された公開鍵と、サーバーで生成した秘密鍵をもとに、共有秘密を計算します。共有秘密から複数の共通鍵を用途に応じて生成します。これらの値は、クライアントがサーバーから共有された公開鍵と、クライアントで生成した秘密鍵をもとに計算したもので、同一の値になります。この過程は鍵スケジュールと呼ばれます。TLS1.3の鍵スケジュールではHMAC-based key derivation function(HKDF)と呼ばれる関数を使い、ExtractとExpandという処理を繰り返し適用して、各種値を計算します。また、計算の入力としてハンドシェイクのメッセージが使われますが、Transcript Hashと呼ばれる計算が行われます。メッセージを連結してハッシュを取ることに相当します。

最初にTranscript Hashを計算する関数の実装を示します。入力として、ハッシュ関数の生成関数と、(ハンドシェイク)メッセージバイト列のリストを取り、ハッシュを取って連結したバイト列を返します。ClientHelloとServerHelloを通して、SHA256を使用することに合意したため、ハッシュ関数はSHA256を使います。

import "hash"

func TranscriptHash(hash func() hash.Hash, messages [][]byte) []byte {
    h := hash() // sha256.New
    for _, message := range messages {
        h.Write(message)
    }
    return h.Sum(nil)
}

次にHKDFとそこから派生する関数のコードを示します。まずはRFC8446に記述されている、入力データの構造と関数の定義です。

struct {
    uint16 length = Length;
    opaque label<7..255> = "tls13 " + Label; 
    opaque context<0..255> = Context;
} HkdfLabel;

HKDF-Expand-Label(Secret, Label, Context, Length) = HKDF-Expand(Secret, HkdfLabel, Length)

Derive-Secret(Secret, Label, Messages) = HKDF-Expand-Label(Secret, Label, Transcript-Hash(Messages), Hash.length)

HKDF-ExpandがHKDFのExpand処理で、そのラッパー関数としてHKDF-Expand-LabelDerive-Secretが定義されています。入力としてSecretという値、文字列のLabel、ハンドシェイクメッセージのリストのMessagesが渡されますが、計算の過程でMessagesのTranscript Hashを取り、LabelとともにHkdfLabelとして定義されたデータ構造にした上で、Expandに渡されます。以下はGoによるこれらの関数の実装です。HKDFのExpandとExtractはgolang.org/x/crypto/hkdfパッケージから利用可能です。Lengthは使用するハッシュ関数に応じて決まります。

import (
    "hash"

    "golang.org/x/crypto/hkdf"
)

type (
    HKDFLabel struct {
        Length  uint16
        Label   string
        Context []byte
    }
)

func (l HKDFLabel) Bytes() []byte {
    label := []byte{}
    label = append(label, byte(uint8(l.Length>>8)))
    label = append(label, byte(uint8(l.Length)))

    labelBytes := []byte(l.Label)
    label = append(label, byte(len(labelBytes)))
    label = append(label, labelBytes...)

    label = append(label, byte(len(l.Context)))
    return append(label, l.Context...)
}

func HKDFExpandLabel(hash func() hash.Hash, secret []byte, label string, content []byte, length int) ([]byte, error) {
    hkdflabel := HKDFLabel{
        Length:  uint16(length),
        Label:   "tls13 " + label,
        Context: content,
    }

    hkdfExpand := hkdf.Expand(hash, secret, hkdflabel.Bytes())
    derivedSecret := make([]byte, length)
    _, err := hkdfExpand.Read(derivedSecret)
    if err != nil {
        return nil, err
    }
    return derivedSecret, nil
}

func DeriveSecret(hash func() hash.Hash, secret []byte, label string, messages [][]byte) ([]byte, error) {
    return HKDFExpandLabel(hash, secret, label, TranscriptHash(hash, messages), hash().Size())
}

これらの関数を使い、鍵スケジュールにしたがって計算を行いますが、その過程を模式的に表したのがRFC8446の以下の図になります。

             0
             |
             v
   PSK ->  HKDF-Extract = Early Secret
             |
             +-----> Derive-Secret(., "ext binder" | "res binder", "")
             |                     = binder_key
             |
             +-----> Derive-Secret(., "c e traffic", ClientHello)
             |                     = client_early_traffic_secret
             |
             +-----> Derive-Secret(., "e exp master", ClientHello)
             |                     = early_exporter_master_secret
             v
       Derive-Secret(., "derived", "")
             |
             v
   (EC)DHE -> HKDF-Extract = Handshake Secret
             |
             +-----> Derive-Secret(., "c hs traffic",
             |                     ClientHello...ServerHello)
             |                     = client_handshake_traffic_secret
             |
             +-----> Derive-Secret(., "s hs traffic",
             |                     ClientHello...ServerHello)
             |                     = server_handshake_traffic_secret
             v
       Derive-Secret(., "derived", "")
             |
             v
   0 -> HKDF-Extract = Master Secret
             |
             +-----> Derive-Secret(., "c ap traffic",
             |                     ClientHello...server Finished)
             |                     = client_application_traffic_secret_0
             |
             +-----> Derive-Secret(., "s ap traffic",
             |                     ClientHello...server Finished)
             |                     = server_application_traffic_secret_0
             |
             +-----> Derive-Secret(., "exp master",
             |                     ClientHello...server Finished)
             |                     = exporter_master_secret
             |
             +-----> Derive-Secret(., "res master",
                                   ClientHello...client Finished)
                                   = resumption_master_secret

今回必要なのはこれらの中の一部なので、必要なものだけに限定します。

             0
             |
             v
     0 ->  HKDF-Extract = Early Secret
             |
             v
       Derive-Secret(., "derived", "")
             |
             v
   (EC)DHE -> HKDF-Extract = Handshake Secret
             |
             +-----> Derive-Secret(., "c hs traffic",
             |                     ClientHello...ServerHello)
             |                     = client_handshake_traffic_secret
             |
             +-----> Derive-Secret(., "s hs traffic",
             |                     ClientHello...ServerHello)
             |                     = server_handshake_traffic_secret
             v
       Derive-Secret(., "derived", "")
             |
             v
   0 -> HKDF-Extract = Master Secret
             |
             +-----> Derive-Secret(., "c ap traffic",
             |                     ClientHello...server Finished)
             |                     = client_application_traffic_secret_0
             |
             +-----> Derive-Secret(., "s ap traffic",
                                  ClientHello...server Finished)
                                   = server_application_traffic_secret_0

もう少し見やすくなりました。PSK(Pre-Shared Key)はフルハンドシェイクではゼロです。(EC)DHEがECDHE鍵交換で得られた共有秘密になります。HKDF-ExtractとEKDF-Expandのラッパー関数であるDerive-Secretを繰り返し適用し、以下のシークレット値を得ています。

  • client_handshake_traffic_secret
    • クライアントが、以降のハンドシェイクメッセージの暗号化に使用する共通鍵を導出するためのシークレット
  • server_handshake_traffic_secret
    • サーバーが使用するclient_handshake_traffic_secretと同様のシークレット
  • client_application_traffic_secret_0
    • クライアントが、ハンドシェイク後のメッセージの暗号化に使用する共通鍵を導出するためのシークレット
  • server_application_traffic_secret_0
    • サーバーが使用するclient_application_traffic_secret_0と同様のシークレット

シークレットの種類ごとに異なるラベルが使われていることがわかります。ClientHello...ServerHelloは、ClientHelloからServerHelloまでのメッセージのリストを表しています。今回の例ではClientHelloとServerHelloの2つだけですが、HelloRetryRequestなど、他のメッセージを交換している場合はそれも順に含めます。同様にClientHello...server FinishedはClientHelloからサーバーが送るFinishedメッセージまでにやり取りしたハンドシェイクメッセージのリストです。これらのシークレットから、共通鍵はHKDF-Expand-Labelにより計算されます(senderはクライアントまたはサーバー)。

[sender]_write_key = HKDF-Expand-Label(Secret, "key", "", key_length)
[sender]_write_iv  = HKDF-Expand-Label(Secret, "iv", "", iv_length)

共通鍵以外に、初期化ベクトル(Initial Vector, IV)も計算しています。これはAEADによる暗号化の際に必要となる値です(後述)。
Goによる実装を以下に示します。

import (
    "crypto/ecdh"
    "crypto/sha256"
    "hash"
    "net"

    "golang.org/x/crypto/hkdf"
)

type (
    Secrets struct {
        Hash            func() hash.Hash
        SharedSecret    []byte
        EarlySecret     []byte
        HandshakeSecret []byte
        MasterSecret    []byte
    }

    HandshakeTrafficSecrets struct {
        ClientHandshakeTrafficSecret []byte
        ServerHandshakeTrafficSecret []byte
        ServerWriteKey               []byte
        ServerWriteIV                []byte
        ClientWriteKey               []byte
        ClientWriteIV                []byte
    }
)

func HandleClientHello(conn net.Conn, clientHelloBytes []byte) (*TLSContext, error) {
    // ...
    // ServerHelloからの処理の続き
    secrets, err := GenerateSecrets(sha256.New, selectedCurve, clientECDHPublicKey, ecdhServerPrivateKey)
    if err != nil {
        return err
    }
    trafficSecrets, err := secrets.HandshakeTrafficKeys(clientHelloBytes, serverHelloTLSRecord.Fragment, 16, 12)
    if err != nil {
        return err
    }
    // 処理が続く
}

func GenerateSecrets(hash func() hash.Hash, curve ecdh.Curve, clientPublicKeyBytes []byte, serverPrivateKey *ecdh.PrivateKey) (*Secrets, error) {
    // Shared secret (Pre-master Secret)
    clientPublicKey, err := curve.NewPublicKey(clientPublicKeyBytes)
    if err != nil {
        return nil, err
    }
    sharedSecret, err := serverPrivateKey.ECDH(clientPublicKey)
    if err != nil {
        return nil, err
    }

    // Early Secret
    zero32 := make([]byte, hash().Size())
    earlySecret := hkdf.Extract(hash, zero32, zero32)

    secretState, err := DeriveSecret(hash, earlySecret, "derived", [][]byte{})
    if err != nil {
        return nil, err
    }
    handshakeSecret := hkdf.Extract(hash, sharedSecret, secretState)

    secretState, err = DeriveSecret(hash, handshakeSecret, "derived", [][]byte{})
    if err != nil {
        return nil, err
    }
    masterSecret := hkdf.Extract(hash, zero32, secretState)
    return &Secrets{
        Hash:            hash,
        SharedSecret:    sharedSecret,
        EarlySecret:     earlySecret,
        HandshakeSecret: handshakeSecret,
        MasterSecret:    masterSecret,
    }, nil
}

func (s *Secrets) HandshakeTrafficKeys(clientHello []byte, serverHello []byte, keyLength int, ivLength int) (*HandshakeTrafficSecrets, error) {
    clientHandshakeTrafficSecret, err := DeriveSecret(s.Hash, s.HandshakeSecret, "c hs traffic", [][]byte{clientHello, serverHello})
    if err != nil {
        return nil, err
    }

    serverHandshakeTrafficSecret, err := DeriveSecret(s.Hash, s.HandshakeSecret, "s hs traffic", [][]byte{clientHello, serverHello})
    if err != nil {
        return nil, err
    }

    serverWriteKey, err := HKDFExpandLabel(s.Hash, serverHandshakeTrafficSecret, "key", []byte{}, keyLength)
    if err != nil {
        return nil, err
    }
    serverWriteIV, err := HKDFExpandLabel(s.Hash, serverHandshakeTrafficSecret, "iv", []byte{}, ivLength)
    if err != nil {
        return nil, err
    }
    clientWriteKey, err := HKDFExpandLabel(s.Hash, clientHandshakeTrafficSecret, "key", []byte{}, keyLength)
    if err != nil {
        return nil, err
    }
    clientWriteIV, err := HKDFExpandLabel(s.Hash, clientHandshakeTrafficSecret, "iv", []byte{}, ivLength)
    if err != nil {
        return nil, err
    }
    return &HandshakeTrafficSecrets{
        ClientHandshakeTrafficSecret: clientHandshakeTrafficSecret,
        ServerHandshakeTrafficSecret: serverHandshakeTrafficSecret,
        ServerWriteKey:               serverWriteKey,
        ServerWriteIV:                serverWriteIV,
        ClientWriteKey:               clientWriteKey,
        ClientWriteIV:                clientWriteIV,
    }, nil
}

GenerateSecrets関数では、ECDHE共有秘密、EarlySecret, Handshake Secret, Master Secretを計算しています。HandshakeTrafficKeysメソッドでは、共有秘密とHandshake Secretからclient_handshake_traffic_secret, servder_handshake_traffic_secretを計算し、さらにサーバーとクライアントの共通鍵と初期化ベクトルを導出しています。サーバーでも、クライアントとサーバーの両方の値を計算していますが、サーバーの共通鍵と初期化ベクトルは、クライアントに送信するメッセージの暗号化に使用し、クライアントの共通鍵と初期化ベクトルは、クライアントから送信されたメッセージの復号に使用します。
ちなみにopenssl s_cientのオプションに-keylogfile keylog.txtがありましたが、このファイルには、上記で計算したHandshake Traffic SecretやApplication Traffic Secretの値が書き込まれています。この値はWireshark等のツールによる、TLS通信のデバッグに使うことができます(筆者自身はまだ試したことがありません)。TLSハンドシェイクの実装という面で言えば、サーバーで計算した値がファイル内の値と一致するかどうかにより、ここまでの実装の正しさを検証できます。
Application Traffic Secretはこの時点では計算していません。この計算には、Server FinishedまでのすべてのハンドシェイクメッセージのTranscript Hashが必要となるため、計算できるのは後になります。

ServerHello後の鍵スケジュールを通して、サーバーとクライアントは暗号化するための準備が整いました。以降のメッセージはすべて暗号化されます。次はCertificateメッセージを見ていきます。なお、EncryptedExtensionsが最初に暗号化されるメッセージですが、他のメッセージに比べて重要ではないため詳しくは省略します。ただしTranscript Hashの計算には使われます。

Certificate

Certificateメッセージでは、サーバーは自身の証明書とそれに連なる中間証明書のチェーンをクライアントに送ります。

これを受け取ったクライアントは以下の検証を行います:

  1. 証明書チェーンをたどり、信頼できるルート認証局(CA)の証明書にたどり着くことを確認
  2. チェーン内のすべての証明書の署名を検証し、各証明書が上位の証明書によって正しく署名されていることを確認
  3. 証明書の有効期限、失効状態(CRLやOCSPを使用)、使用目的(サーバー認証用であること)などを確認
  4. サーバーの証明書に記載されているドメイン名と、接続先のサーバーのドメイン名が一致しているかを確認

ただし、今回は自己署名証明書を使用し、ハンドシェイクの実装もサーバー側のみ行っているため、クライアントでの検証には深く立ち入りません(opensslで自らルート認証局から作成し、サーバー証明書への署名を行い、ルート証明書をOSの証明書ストアに一時的に追加すると、より現実に即した検証になるかもしれません)。
自己署名証明書と対応する秘密鍵は、make server-crtにより、それぞれserver.crt, server.keyという名前で生成されます。使用する公開鍵アルゴリズムは楕円曲線secp256r1です。

以下は、カレントディレクトリからサーバー証明書を読み込み、Certificateメッセージを構築するコードです。

import (
    "crypto/tls"
    "net"             
)

type (
    CertificateType  uint8     
    CertificateEntry struct {  
        CertType CertificateType        
        CertData []byte
    }
    CertificateMessage struct {
        CertificateRequestContext []byte
        CertificateList           []CertificateEntry
    }
) 
  
const (
    X509         CertificateType = 0x01
    RawPublicKey CertificateType = 0x02
)

func HandleClientHello(conn net.Conn, clientHelloBytes []byte) (*TLSContext, error) {
    // ...
    // Key schedule, EncryptecExtensionsからの処理の続き
    serverCert, err := tls.LoadX509KeyPair("server.crt", "server.key")
    if err != nil {
        return err
    }
    certificateMessage := CertificateMessage{
        CertificateRequestContext: []byte{},
        CertificateList: []CertificateEntry{
            {
                CertType: X509,
                CertData: serverCert.Certificate[0],
            },
        },
    }
    handshakeCertificate := Handshake[CertificateMessage]{
        MsgType:          Certificate, // 0x0b
        Length:           uint32(len(certificateMessage.Bytes())),
        HandshakeMessage: certificateMessage,
    }
    // 処理が続く
}

平文のメッセージであれば、このあとTLSヘッダーを追加し、TLSレコードとして送信するわけですが、Certificate以降のメッセージは暗号化されます。この場合はハンドシェイクメッセージを表す0x16を後ろに追加して暗号化し、(ハンドシェイクではなく)アプリケーションデータ(ContentType=0x17)のTLSヘッダーを追加して送信します。

暗号化は、合意した暗号アルゴリズムTLS_AES_128_GCM_SHA256を使用します。AES-GCMというAEADによるブロック暗号です。AEADは、秘匿性、完全性、正真性を同時に実現する暗号方式で、暗号化されないTLSヘッダー部分に対しても完全性が保証されます。以下に、暗号化処理及びTLSレコード送信までのコードを示します。

import (
    "crypto/aes"
    "crypto/cipher"
    "encoding/binary"
    "net"
)

type (
    TLSInnerPlainText struct {
        Content     []byte
        ContentType ContentType // real content type
        Zeros       []byte      // padding
    }
    TLSRecord struct {
        ContentType         ContentType
        LegacyRecordVersion ProtocolVersion
        Length              uint16
        Fragment            []byte
    }
)

func HandleClientHello(conn net.Conn, clientHelloBytes []byte) (*TLSContext, error) {
    // ...
    // 処理の続き
    certificateTLSRecord, err := NewTLSCipherMessageText(
        trafficSecrets.ServerWriteKey,
        trafficSecrets.ServerWriteIV,
        TLSInnerPlainText{
            Content:     handshakeCertificate.Bytes(),
            ContentType: HandshakeRecord, // 0x16
        },
        1, // sequence number
    )
    if err != nil {
        return err
    }
    if _, err = conn.Write(certificateTLSRecord.Bytes()); err != nil {
        return err
    }
    // 処理が続く
}

func NewTLSCipherMessageText(key, iv []byte, plaintext TLSInnerPlainText, sequenceNumber uint64) (*TLSRecord, error) {
    encryptedRecord, err := EncryptTLSInnerPlaintext(key, iv, plaintext.Bytes(), sequenceNumber)
    if err != nil {
        return nil, err
    }
    return &TLSRecord{
        ContentType:         ApplicationDataRecord, // 0x17
        LegacyRecordVersion: TLS12,                 // 0x0303
        Length:              uint16(len(encryptedRecord)),
        Fragment:            encryptedRecord,
    }, nil
}

func EncryptTLSInnerPlaintext(key, iv []byte, tlsInnerPlainText []byte, sequenceNumber uint64) ([]byte, error) {
    block, err := aes.NewCipher(key)
    if err != nil {
        return nil, err
    }
    aesgcm, err := cipher.NewGCM(block)
    if err != nil {
        return nil, err
    }
    tlsCipherTextLength := len(tlsInnerPlainText) + aesgcm.Overhead()
    additionalData := []byte{  
        byte(ApplicationDataRecord),          // 0x17
        byte(TLS12 >> 8), byte(TLS12 & 0xff), // 0x0303
        byte(tlsCipherTextLength >> 8), byte(tlsCipherTextLength),
    }
    encrypted := aesgcm.Seal(nil, calculateNonce(iv, sequenceNumber), tlsInnerPlainText, additionalData)       
    return encrypted, nil  
}

func calculateNonce(iv []byte, sequenceNumber uint64) []byte {
    sequenceNumberBytes := make([]byte, 8)
    binary.BigEndian.PutUint64(sequenceNumberBytes, sequenceNumber)
    paddedSequenceNumber := make([]byte, len(iv))
    copy(paddedSequenceNumber[len(iv)-8:], sequenceNumberBytes)
    nonce := make([]byte, len(iv))
    for i := range iv {
        nonce[i] = paddedSequenceNumber[i] ^ iv[i]
    }
    return nonce
}

AES-GCMによる暗号化にはcrypto/aescrypto/cipherパッケージを使用しています。ここではAES-GCMのアルゴリズムの詳細には立ち入りませんが、入力にNonceと呼ばれる値と、暗号化されないTLSレコードのヘッダーが追加データ(Additional Data)として含まれているのが特筆すべき点です。TLSヘッダーをAES-GCMの計算に含めることで、メッセージの受け取り手がTLSヘッダーの完全性の検証を行えます。具体的には、復号したメッセージから取り出した追加データと、暗号化されていないTLSヘッダーを比較し、TLSヘッダーの改ざんを検出できます。
Nonceは暗号操作ごとに一意の12バイトの値です。共通鍵と合わせて生成したIV及び、暗号操作ごとに増加する値(シーケンス番号)をもとに計算します。シーケンス番号は8バイトの値で、Nonceの計算ではビッグエンディアンでバイト列に変換します。その後IVの長さ(この場合は12バイト)に合わせて、右詰めで0によりパディングします。そしてパディングされたシーケンス番号とIVの排他的論理和を取ります。これにより、同じ鍵を使用しても毎回異なるNonceが生成され、リプレイ攻撃などを防ぐことができます。上記のCertificateメッセージの例ではシーケンス番号は1としていますが、これは省略したEncryptedExtensionsメッセージで0を使っていたためです。

CertificateVerify

Certificateメッセージの次のメッセージは、CertificateVerifyメッセージです。これは、Certicateメッセージで送ったサーバー証明書の公開鍵とペアになる秘密鍵の所有を証明するためのメッセージです。そのため、秘密鍵を用いて、ClientHelloからCertificateまでのハンドシェイクメッセージのTranscript Hashに対し、デジタル署名を行います。

ClientHelloのSupported Signature Algorithms拡張を通して合意した、ecdsa_secp256r1_sha256を署名アルゴリズムとして使用します。署名対象となるのは、以下の内容を連結したものです。

  • 0x20の64回の繰り返し(以前のTLSバージョンにあった脆弱性への対策)
  • コンテキスト文字列: "TLS 1.3, server CertificateVerify"
  • セパレーター: 0x00
  • ClientHelloからCertificateまでのTranscript Hash

以下は、この仕様に従いデジタル署名を計算するコードです(エドワーズ曲線デジタル署名アルゴリズムed25519も想定したコードになっています)

import (
    "bytes"
    "crypto"
    "crypto/ecdsa"
    "crypto/ed25519"
    "crypto/rand"
    "crypto/sha256"
    "crypto/tls"
    "fmt"
    "hash"
    "net"
)

func HandleClientHello(conn net.Conn, clientHelloBytes []byte) (*TLSContext, error) {
    // ...
    // 処理の続き
    serverCert, err := tls.LoadX509KeyPair("server.crt", "server.key")
    if err != nil {
        return err
    }
    signature, err := SignCertificate(
        sha256.New,
        serverCert.PrivateKey,
        [][]byte{
            clientHelloBytes,                     // ClientHello
            serverHelloTLSRecord.Fragment,        // ServerHello
            handshakeEncryptedExtensions.Bytes(), // EncryptedExtensions
            handshakeCertificate.Bytes(),         // Certificate
        },
    )
    // 処理が続く
}

func SignCertificate(hash func() hash.Hash, priv crypto.PrivateKey, handshakeMessages [][]byte) ([]byte, error) {
    signatureTarget := bytes.Repeat([]byte{0x20}, 64)
    signatureTarget = append(signatureTarget, []byte("TLS 1.3, server CertificateVerify")...)
    signatureTarget = append(signatureTarget, 0x00) // separator
    signatureTarget = append(signatureTarget, TranscriptHash(hash, handshakeMessages)...)

    switch privKey := priv.(type) {
    case ed25519.PrivateKey:
        // For Ed25519, the hashing is done internally, so we directly pass the message to be signed.
        return ed25519.Sign(privKey, signatureTarget), nil
    case *ecdsa.PrivateKey:
        hashed := sha256.Sum256(signatureTarget)
        return ecdsa.SignASN1(rand.Reader, privKey, hashed[:])
    default:
        return nil, fmt.Errorf("unsupported private key type: %T", priv)
    }
}

Certificateメッセージと同様に、AES-GCMで暗号化し、ApplicationDataのTLSレコードとしてクライアントに送信します。Nonceの計算に使用するシーケンス番号を2にしています。

type (
    SignatureScheme          uint16
    CertificateVerifyMessage struct {
        Algorithm SignatureScheme
        Signature []byte
    }
)

const (
    ECDSA_SECP256R1_SHA256 SignatureScheme = 0x0403
    ECDSA_SECP384R1_SHA384 SignatureScheme = 0x0503
    ECDSA_SECP521R1_SHA512 SignatureScheme = 0x0603
    ED25519                SignatureScheme = 0x0807
)

func HandleClientHello(conn net.Conn, clientHelloBytes []byte) (*TLSContext, error) {
    // ...
    // 処理の続き
    certificateVerifyMessage := CertificateVerifyMessage{
        Algorithm: ECDSA_SECP256R1_SHA256, // 0x0403
        Signature: signature,
    }
    handshakeCertificateVerify := Handshake[CertificateVerifyMessage]{
        MsgType:          CertificateVerify, // 0x0f
        Length:           uint32(len(certificateVerifyMessage.Bytes())),
        HandshakeMessage: certificateVerifyMessage,
    }
    certificateVerifyTLSRecord, err := NewTLSCipherMessageText(
        trafficSecrets.ServerWriteKey,
        trafficSecrets.ServerWriteIV,
        TLSInnerPlainText{
            Content:     handshakeCertificateVerify.Bytes(),
            ContentType: HandshakeRecord, // 0x16
        },
        2, // sequence number
    )
    if err != nil {
        return err
    }
    if _, err = conn.Write(certificateVerifyTLSRecord.Bytes()); err != nil {
        return err
    }
    // 処理が続く
}

Finished(Server)

サーバーが送信する最後のハンドシェイクメッセージは、Finishedメッセージです。Finishedメッセージでは、これまでのハンドシェイクメッセージの完全性を検証します。サーバーが送信したFinishedメッセージをクライアントが検証し、サーバーに対してFinishedメッセージを返します。サーバーは同様に、クライアントから送信されたFinishedメッセージを検証します。

完全性の検証にはHMACを使用します。HMACは認証コードの一つで、入力メッセージと共通鍵を用いてハッシュ関数によりハッシュ値を計算します。ここで使用する共通鍵は、server_handshake_traffic_secretから新たに伸長します。入力メッセージは、ClientHelloからCertificateVerifyまでのハンドシェイクメッセージのTrascript Hashです。
以下は、Finishedメッセージの構築し、クライアントへ送信するコードです。HKDF-Expand-Labelにより、新たに共通鍵を伸長しています。

import (
    "crypto/hmac"
    "crypto/sha256"
    "hash"
    "net"
)

type (
    FinishedMessage struct {
        VerifyData []byte
    }
)

func HandleClientHello(conn net.Conn, clientHelloBytes []byte) (*TLSContext, error) {
    // ...
    // 処理の続き
    finishedMessage, err := NewFinishedMessage(
        sha256.New,
        trafficSecrets.ServerHandshakeTrafficSecret,
        [][]byte{
            clientHelloBytes,                     // ClientHello
            serverHelloTLSRecord.Fragment,        // ServerHello
            handshakeEncryptedExtensions.Bytes(), // EncryptedExtensions
            handshakeCertificate.Bytes(),         // Certificate
            handshakeCertificateVerify.Bytes(),   // CertificateVerify
        },
    )
    if err != nil {
        return err
    }
    handshakeFinished := Handshake[FinishedMessage]{
        MsgType:          Finished, // 0x14
        Length:           uint32(len(finishedMessage.Bytes())),
        HandshakeMessage: finishedMessage,
    }
    finishedTLSRecord, err := NewTLSCipherMessageText(
        trafficSecrets.ServerWriteKey,
        trafficSecrets.ServerWriteIV,
        TLSInnerPlainText{
            Content:     handshakeFinished.Bytes(),
            ContentType: HandshakeRecord, // 0x16
        },
        3, // sequence number
    )
    if err != nil {
        return err
    }
    if _, err = conn.Write(finishedTLSRecord.Bytes()); err != nil {
        return err
    }
    // 処理が続く
}

func NewFinishedMessage(hash func() hash.Hash, baseKey []byte, handshakeMessages [][]byte) (FinishedMessage, error) {
    finishedKey, err := HKDFExpandLabel(hash, baseKey, "finished", []byte{}, hash().Size())
    if err != nil {
        return FinishedMessage{}, err
    }
    h := hmac.New(hash, finishedKey)
    h.Write(TranscriptHash(hash, handshakeMessages))
    return FinishedMessage{
        VerifyData: h.Sum(nil),
    }, nil
}

以上で、ClientHelloメッセージに対するサーバーの処理はひと通り終了です。ここまでに登場した様々な値をTLSContext構造体に保存しておきます。これらは後続のClient Finishedメッセージや、アプリケーションデータの処理で必要になります。

type (
    Secrets struct {
        Hash            func() hash.Hash
        SharedSecret    []byte
        EarlySecret     []byte
        HandshakeSecret []byte
        MasterSecret    []byte
    }
    HandshakeTrafficSecrets struct {
        ClientHandshakeTrafficSecret []byte
        ServerHandshakeTrafficSecret []byte
        ServerWriteKey               []byte
        ServerWriteIV                []byte
        ClientWriteKey               []byte
        ClientWriteIV                []byte
    }
    ApplicationTrafficSecrets struct {
        ClientApplicationTrafficSecret []byte
        ServerApplicationTrafficSecret []byte
        ClientWriteKey                 []byte
        ClientWriteIV                  []byte
        ServerWriteKey                 []byte
        ServerWriteIV                  []byte
    }
    TLSContext struct {
        Secrets                      Secrets
        HandshakeTrafficSecrets      HandshakeTrafficSecrets
        ApplicationTrafficSecrets    ApplicationTrafficSecrets
        HandshakeClientHello         []byte
        HandshakeServerHello         []byte
        HandshakeEncryptedExtensions []byte
        HandshakeCertificate         []byte
        HandshakeCertificateVerify   []byte
        ServerFinished               []byte
    }
)

func HandleClientHello(conn net.Conn, clientHelloBytes []byte) (*TLSContext, error) {
    // ...
    // 処理の続き
    return &TLSContext{
        Secrets:                      *secrets,
        HandshakeTrafficSecrets:      *trafficSecrets,
        HandshakeClientHello:         clientHelloBytes,
        HandshakeServerHello:         serverHelloTLSRecord.Fragment,
        HandshakeEncryptedExtensions: handshakeEncryptedExtensions.Bytes(),
        HandshakeCertificate:         handshakeCertificate.Bytes(),
        HandshakeCertificateVerify:   handshakeCertificateVerify.Bytes(),
        ServerFinished:               handshakeFinished.Bytes(),
    }, nil
}

Finished(Client)

クライアントは、サーバーから受け取ったFinisheメッセージを復号し、HMACの検証を行います。すなわち、サーバーのfinished keyを使用して自ら計算したHMACが、サーバーから送られてきたものと同一であるかを確かめます。検証に成功すると、クライアントはサーバーに対し、同様にFinishedメッセージを送信します。サーバーはFinishedメッセージを受け取ると、同様にしてHMACの検証を行います。検証に成功すると、ハンドシェイクは完了となり、TLSの通信が確立します。

また、今回このFinishedメッセージは、サーバーが初めて受け取る暗号化されたメッセージです。復号処理とHMACの検証を見ていきます。
まずは、実際のFinishedメッセージのTLSレコードのバイト列を見ていきます。

17 03 03 00 35 12 1e 80-f5 51 a4 bd 24 c2 6c 0f
29 80 60 cf c6 a1 1b 15-31 54 51 1a 8a b8 78 9e
0a 42 59 5d 87 97 9c a2-a2 b6 61 f0 ed 05 52 9e
5a 02 b2 d8 24 82 ac 62-75 2b

暗号化されているため、TLSレコードとしてはApplication Data(ContentType=0x017)のように見えています。最初の5バイトのTLSヘッダー17 03 03 00 35を除いた部分が、暗号化されたペイロードです。

以下のコードは、Finishedメッセージを受け取った後の処理の流れを示したものです。復号のためには、クライアントが暗号化に使った共通鍵と初期化ベクトルを用いる必要があります。これはClientHelloの処理における鍵スケジュールで計算し、TLSContext構造体に保存していました。それを取り出して使います。

import (
    "encoding/binary"
    "io"
    "net"
)

func Handle(conn net.Conn, prevContext *TLSContext) {
    // Read TLS Record header
    tlsHeaderBuffer := make([]byte, 5)
    conn.Read(tlsHeaderBuffer)
    length := binary.BigEndian.Uint16(tlsHeaderBuffer[3:5])
    // Read TLS Record payload
    payloadBuffer := make([]byte, length)
    io.ReadFull(conn, payloadBuffer)

    tlsRecord := &TLSRecord{
        ContentType:         ContentType(tlsHeaderBuffer[0]),
        LegacyRecordVersion: ProtocolVersion(binary.BigEndian.Uint16(tlsHeaderBuffer[1:3])),
        Length:              length,
        Fragment:            payloadBuffer,
    }
    switch tlsRecord.ContentType {
    case ApplicationDataRecord: // 0x17
        // ハンドシェイク完了前はHandshake Traffic Secretsから伸長した鍵とIVを用いる
        // ハンドシェイク完了後はApplication Traffic Secretsから伸長した鍵とIVを用いる
        key = prevTLSContext.HandshakeTrafficSecrets.ClientWriteKey
        iv = prevTLSContext.HandshakeTrafficSecrets.ClientWriteIV
     // 送受信回数に応じてシーケンス番号は増加させる
        sequenceNumber := 0
        decryptedRecord, err := DecryptTLSInnerPlaintext(key, iv, tlsRecord.Fragment, sequenceNumber, tlsHeaderBuffer)
        tlsInnerPlainText := TLSInnerPlainText{
            Content:     decryptedRecord[:len(decryptedRecord)-1],
            ContentType: ContentType(decryptedRecord[len(decryptedRecord)-1]),
        }
        switch tlsInnerPlainText.ContentType {
        case HandshakeRecord: // 0x16
            msgType := HandshakeType(tlsInnerPlainText.Content[0])
            handshakeLength := (uint32(tlsInnerPlainText.Content[1]) << 16) | (uint32(tlsInnerPlainText.Content[2]) << 8) | uint32(tlsInnerPlainText.Content[3])
            switch msgType {
            case Finished: // 0x14
                newContext, err := HandleFinished(tlsInnerPlainText.Content, prevTLSContext)
            }
        case ApplicationDataRecord: // 0x17
            alert := HandleApplicationData(conn, tlsInnerPlainText, prevTLSContext, seqNum, applicationBuffer)
        }
    }
}

復号処理を行う関数DecryptTLSInnerPlaintextは以下のようなっています。

import (
    "crypto/aes"
    "crypto/cipher"
)

func DecryptTLSInnerPlaintext(key, iv []byte, encryptedTLSInnerPlainText []byte, sequenceNumber uint64, tlsHeader []byte) ([]byte, error) {
    block, err := aes.NewCipher(key)
    if err != nil {
        return nil, err
    }
    aesgcm, err := cipher.NewGCM(block)
    if err != nil {
        return nil, err
    }
    return aesgcm.Open(nil, calculateNonce(iv, sequenceNumber), encryptedTLSInnerPlainText, tlsHeader)
}

AES-GCMによる暗号化ではSealメソッドを使用していましたが、復号ではOpenになっています。入力には暗号化のときと同様、Nonceと追加データであるTLSヘッダーが含まれています。復号により得られたバイト列は以下のようになります。

14 00 00 20 aa 6c 54 15-aa 69 d9 d1 09 9a 81 9c
8e 93 02 22 53 38 79 95-9d 0f 00 24 35 2a a9 36
c3 fa ca e5 16

最後のバイトが、ハンドシェイクを表す0x16になっています。最初の4バイト14 00 00 20はハンドシェイクメッセージのヘッダーで、残りのaa 6c 54 15-aa 69 d9 d1 09 9a 81 9c 8e 93 02 22 53 38 79 95-9d 0f 00 24 35 2a a9 36 c3 fa ca e5がクライアント側で計算されたHMACの値です。
以下のコードでは、サーバー側でもHMACを計算し、クライアントの値と比較を行っています。HMACの計算では、client_handshake_traffic_secretをもとにfinished keyを伸長し、サーバーのFinishedメッセージまで含めたTranscript Hashに対し、処理を行っています。

import (
    "bytes"
    "crypto/sha256"
)

type (
    FinishedMessage struct {
        VerifyData []byte
    }
)

func HandleFinished(finishedBytes []byte, prevTLSContext *TLSContext) (*TLSContext, error) {
    // 4 bytes offset by TLS Record ContentType and Length
    clientSentFinishedMessage := FinishedMessage{
        VerifyData: finishedBytes[4:],
    }
    serverCalculatedFinishedMessage, err := NewFinishedMessage(
        sha256.New,
        prevTLSContext.HandshakeTrafficSecrets.ClientHandshakeTrafficSecret,
        [][]byte{
            prevTLSContext.HandshakeClientHello,
            prevTLSContext.HandshakeServerHello,
            prevTLSContext.HandshakeEncryptedExtensions,
            prevTLSContext.HandshakeCertificate,
            prevTLSContext.HandshakeCertificateVerify,
            prevTLSContext.ServerFinished,
        },
    )
    if err != nil {
        return prevTLSContext, err
    }
    // Finished message verification
    if !bytes.Equal(clientSentFinishedMessage.VerifyData, serverCalculatedFinishedMessage.VerifyData) {
        return prevTLSContext, err
    }
    // 処理が続く
}

この検証に成功すれば、ハンドシェイクが完了です!最後に、アプリケーションデータの暗号化に用いる共通鍵と初期化ベクトルを計算し、TLSContextに保存しておきます。Server Finishedメッセージまで揃っているため計算ができます。マスターシークレットからclient_application_traffic_secret_0(復号用)及びserver_application_traffic_secret_0(暗号化用)を導出し、そこからアプリケーションデータ用の共通鍵と初期化ベクトルを伸長しています。

func HandleFinished(finishedBytes []byte, prevTLSContext *TLSContext) (*TLSContext, error) {
    // 処理の続き
    appTrafficSecrets, err := prevTLSContext.Secrets.ApplicationTrafficKeys(
        prevTLSContext.HandshakeClientHello,
        prevTLSContext.HandshakeServerHello,
        prevTLSContext.HandshakeEncryptedExtensions,
        prevTLSContext.HandshakeCertificate,
        prevTLSContext.HandshakeCertificateVerify,
        prevTLSContext.ServerFinished,
        16,
        12,
    )
    if err != nil {
        return prevTLSContext, err
    }
    newTLSContext := prevTLSContext
    newTLSContext.ApplicationTrafficSecrets = *appTrafficSecrets
    return newTLSContext, nil
}

func (s *Secrets) ApplicationTrafficKeys(
    clientHello []byte,
    serverHello []byte,
    encryptedExtensions []byte,
    certificate []byte,
    certificateVerify []byte,
    serverFinished []byte,
    keyLength int,
    ivLength int,
) (*ApplicationTrafficSecrets, error) {
    messages := [][]byte{clientHello, serverHello, encryptedExtensions, certificate, certificateVerify, serverFinished}
    clientApplicationTrafficSecret, err := DeriveSecret(s.Hash, s.MasterSecret, "c ap traffic", messages)
    if err != nil {
        return nil, err
    }

    serverApplicationTrafficSecret, err := DeriveSecret(s.Hash, s.MasterSecret, "s ap traffic", messages)
    if err != nil {
        return nil, err
    }

    serverWriteKey, err := HKDFExpandLabel(s.Hash, serverApplicationTrafficSecret, "key", []byte{}, keyLength)
    if err != nil {
        return nil, err
    }
    serverWriteIV, err := HKDFExpandLabel(s.Hash, serverApplicationTrafficSecret, "iv", []byte{}, ivLength)
    if err != nil {
        return nil, err
    }
    clientWriteKey, err := HKDFExpandLabel(s.Hash, clientApplicationTrafficSecret, "key", []byte{}, keyLength)
    if err != nil {
        return nil, err
    }
    clientWriteIV, err := HKDFExpandLabel(s.Hash, clientApplicationTrafficSecret, "iv", []byte{}, ivLength)
    if err != nil {
        return nil, err
    }
    return &ApplicationTrafficSecrets{
        ClientApplicationTrafficSecret: clientApplicationTrafficSecret,
        ServerApplicationTrafficSecret: serverApplicationTrafficSecret,
        ClientWriteKey:                 clientWriteKey,
        ClientWriteIV:                  clientWriteIV,
        ServerWriteKey:                 serverWriteKey,
        ServerWriteIV:                  serverWriteIV,
    }, nil
}

Application Data

最後に、非常に単純なアプリケーションデータの処理の例を示します。

以下のコードは、クライアントから送信されてきたデータが

GET / HTTP/1.1
Host: localhost

だった場合に、

HTTP/1.1 200 OK
Content-Length: 16
Hello, TLS 1.3!

というレスポンスを返すものです。アプリケーションデータは、client_application_traffic_secret_0から伸長した、クライアントの共通鍵と初期化ベクトルにより暗号化されています。下記のコードは復号後の処理を示しています。

import (
    "net"
    "strings"
)

type (
    AlertLevel       uint8
    AlertDescription uint8
    Alert struct {
        Level       AlertLevel
        Description AlertDescription
    }
    sequenceNumbers struct {
        HandshakeKeySeqNum uint64
        AppKeyClientSeqNum uint64
        AppKeyServerSeqNum uint64
    }
)

const close_notify AlertDescription = 0
var acceptableGetRequest = "GET / HTTP/1.1\r\nHost: localhost\r\n"

func HandleApplicationData(
    conn net.Conn,
    tlsInnerPlainText TLSInnerPlainText,
    tlsContext *TLSContext,
    seqNum *sequenceNumbers,
    applicationBuffer *[]byte,
) *Alert {
    *applicationBuffer = append(*applicationBuffer, tlsInnerPlainText.Content...)
    requestMessage := string(*applicationBuffer)
    if strings.HasSuffix(requestMessage, "\r\n\r\n") {
        if strings.HasPrefix(requestMessage, acceptableGetRequest) {
            response := "HTTP/1.1 200 OK\r\nContent-Length: 16\r\n\r\nHello, TLS 1.3!\n"
            encryptedResponse, err := NewTLSCipherMessageText(
                tlsContext.ApplicationTrafficSecrets.ServerWriteKey,
                tlsContext.ApplicationTrafficSecrets.ServerWriteIV,
                TLSInnerPlainText{
                    Content:     []byte(response),
                    ContentType: ApplicationDataRecord,
                },
                seqNum.AppKeyServerSeqNum,
            )
            if err != nil {
                return &internalErrorAlert
            }
            if _, err = conn.Write(encryptedResponse.Bytes()); err != nil {
                return &Alert{Level: warning, Description: close_notify}
            }
            seqNum.AppKeyServerSeqNum++
        }
        return &Alert{Level: warning, Description: close_notify}
    }
    return nil
}

Alertという構造体はAlertプロトコル用のデータ構造です。TLSでは、エラーや通信の終了を、Alertプロトコルを通して相手に伝えます。close_notifyは正常終了を相手に伝えます。もちろんこれも共通鍵により暗号化して送ります(コードは省略)。

実行例

openssl s_client

$ openssl s_client -connect localhost:443 -CAfile server.crt -tls1_3 -noservername -crlf -curves x25519:secp256r1 -sigalgs ECDSA+SHA256:ed25519              
Connecting to ::1
CONNECTED(00000005)
depth=0 C=JP, ST=Tokyo, L=Tokyo, O=MyOrganization, OU=MyUnit, CN=localhost
verify return:1
---
Certificate chain
 0 s:C=JP, ST=Tokyo, L=Tokyo, O=MyOrganization, OU=MyUnit, CN=localhost
   i:C=JP, ST=Tokyo, L=Tokyo, O=MyOrganization, OU=MyUnit, CN=localhost
   a:PKEY: id-ecPublicKey, 256 (bit); sigalg: ecdsa-with-SHA256
   v:NotBefore: Sep  1 02:20:53 2024 GMT; NotAfter: Sep  1 02:20:53 2025 GMT
---
Server certificate
-----BEGIN CERTIFICATE-----
MIICLDCCAdGgAwIBAgIUeh0B5w5yWTPwmoz0ZOovGMS8O0IwCgYIKoZIzj0EAwIw
azELMAkGA1UEBhMCSlAxDjAMBgNVBAgMBVRva3lvMQ4wDAYDVQQHDAVUb2t5bzEX
MBUGA1UECgwOTXlPcmdhbml6YXRpb24xDzANBgNVBAsMBk15VW5pdDESMBAGA1UE
AwwJbG9jYWxob3N0MB4XDTI0MDkwMTAyMjA1M1oXDTI1MDkwMTAyMjA1M1owazEL
MAkGA1UEBhMCSlAxDjAMBgNVBAgMBVRva3lvMQ4wDAYDVQQHDAVUb2t5bzEXMBUG
A1UECgwOTXlPcmdhbml6YXRpb24xDzANBgNVBAsMBk15VW5pdDESMBAGA1UEAwwJ
bG9jYWxob3N0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEuXIw1zkoOt+47JX3
bwndYT7/8Cw9PDBcObvhSVH8D2g5URSSSHnquLLBk/EYZ2As0tPjW+QKFUIHDWG/
TBr9n6NTMFEwHQYDVR0OBBYEFO5SEkt+eWuFge5JSj6HoL2m8e8mMB8GA1UdIwQY
MBaAFO5SEkt+eWuFge5JSj6HoL2m8e8mMA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZI
zj0EAwIDSQAwRgIhAKCuGJ8GvgLKVz5/osAkkd9eoJ2EFu1HDwy8wl6sohF6AiEA
wgwHX6V0ut1hA+pWL55NK/5ClqIjKs6scvi/e+bys24=
-----END CERTIFICATE-----
subject=C=JP, ST=Tokyo, L=Tokyo, O=MyOrganization, OU=MyUnit, CN=localhost
issuer=C=JP, ST=Tokyo, L=Tokyo, O=MyOrganization, OU=MyUnit, CN=localhost
---
No client certificate CA names sent
Peer signing digest: SHA256
Peer signature type: ECDSA
Server Temp Key: X25519, 253 bits
---
SSL handshake has read 909 bytes and written 247 bytes
Verification: OK
---
New, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256
Server public key is 256 bit
This TLS version forbids renegotiation.
Compression: NONE
Expansion: NONE
No ALPN negotiated
Early data was not sent
Verify return code: 0 (ok)
---
GET / HTTP/1.1
Host: localhost

HTTP/1.1 200 OK
Content-Length: 16

Hello, TLS 1.3!
closed

curl

$ curl -k https://localhost:443 
Hello, TLS 1.3!

Chromeブラウザ

まとめ・感想

暗号の基本知識から始まり、TLSの概要、そしてGoによるフルハンドシェイクの実装を詳しく見てきました。実際に手を動かして実装してみると、理解が不十分だった部分が浮き彫りになり、より正確な理解へと繋がりました。暗号理論の理解も整理することができました。特に実装の過程では、ひっきりなしにAIと対話をし、暗号やRFCを理解を深めることができました。
また、ネットワークプロトコルの実装の雰囲気も知ることができました。Goは自前でTLS実装しているだけあって、暗号処理関連のパッケージが充実していて、インターフェースも非常に使いやすいと感じました。RFCに従った自然な実装がしやすかったと思います。ブラウザでHello, TLS 1.3!と表示されたときは非常に嬉しかったです。

参考文献

  • https://tex2e.github.io/rfc-translater/html/rfc8446.html
    • 原典となるのはRFC8446ですが、原文と日本語訳が並列されているこちらのサイトを主に参照していました。非常に素晴らしいサイトです。
  • Ivan Ristic, “Bulletproof TLS and PKI, Second Edition”, Feisty Duck(邦訳: 齋藤孝道 監訳, 『プロフェッショナルTLS&PKI 改題第2版』, ラムダノート)
    • TLSを学び始めるのに読んだ本です。TLS1.3, 1.2両方の解説があるため、違いもわかりやすくなっています。RFCと照らし合わせても読みやすいです。PKIについての詳細と、それが持つ課題も書かれています。後半は様々なセキュリティの問題や攻撃事例が述べられていて、難しい部分もありましたが、興味深い内容でした。
  • 結城浩, 『暗号技術入門 第3版』, SBクリエイティブ
    • 暗号理論の基礎についてわかりやすく書かれている本で、非常に有用で勉強になりました。暗号理論は代数学により裏打ちされていますが、専門的な代数の知識がなくても、どのような計算が行われているかの雰囲気がつかめるように書かれていました。小さい数を使った、暗号計算の手計算例が書かれていたのも良かったです。例示は理解の試金石。
  • 古城 隆, 松尾 卓幸, 宮崎 秀樹, 須賀 葉子, 『徹底解剖 TLS 1.3』, 翔泳社
    • TLS1.3にテーマを絞っているという面白い本です。こちらもプロフェッショナルTLS&PKIと合わせて、TLSの理解に大変役立ちました。TLSの標準に関する整理も良いです。後半ではC言語による実装が述べられていますが、普段C言語を書かない自分は読むのにやや苦労しました。さらに、パフォーマンス向上のための最適化についても解説されていましたが、こちらは全然ついていけませんでした。製品開発としてTLS実装が必要になった際には有用そうです。
  • Zenn: golangで作るTLS1.3プロトコル
    • すでにGoでTLS1.3(クライアント)を実装し、記事を書かれていた方がいました。この記事も大変参考になりました。特に鍵スケジュールの計算が合わずにハマっていた際、こちらのコードをクローンしてデバッグに使用することで、コードの間違いを特定することができました。
  • 市原創,板倉広明, SSL/TLS実践入門 ──Webの安全性を支える暗号化技術の設計思想
    • こちらは、自分がTLS1.3実装を行ったあとに出た本ですが、SSL/TLSの歴史、暗号理論の基礎、TLSの標準とPKI、攻撃や脆弱性など、TLSに関する主要なトピックが非常にわかりやすく書かれた本でした。攻撃手法に関しては図もあり、わかりやすかったです。

Discussion