🍻

golangで作るTLS1.2プロトコル

2022/04/16に公開

はじめに

前回自作でTCPIP+HTTPを実装して動作を確認することができました。
しかしご覧頂いた方はおわかりのように、通信はHTTP=平文でやり取りされておりパスワードなど機密情報が用意に見れてしまう状態です。

普段我々がブラウザに安心してパスワードを入力しているのは通信がTLSで暗号化されているからです。ではそのTLSの仕組みはどうなっているのでしょう?
恥ずかしい限りですが僕はわかりません。😇😇😇

ということで以下を読みながらTLSプロトコルを自作してみてその仕組みを学ぶことにします。

今回の実装方針です。

  • TLS1.2プロトコルを自作する
  • 暗号化などの処理はcryptパッケージの関数を適時利用する
  • tcp接続にはconnectを使う
  • 鍵交換はまずRSAで作成する
  • TLS_RSA_WITH_AES_128_GCM_SHA256をサポートする

さすがに暗号化などの処理自体も独自で作るのは無理があろうと思われますので、そのあたりはcryptoパッケージを使います。
また前回TCPのハンドシェイクする処理を作りましたが、そこは使わず今回はconnectを使ってSkipします。debugが面倒なのとTLSの実装に注力するためです。
connectでTCP接続したあとは、writeとrecvしか使いませんので、まぁ自作と言えるでしょう。

前回と同じくgolang初心者なので変なコードが多分にあるでしょうが、生暖かい目でよろしくお願いします。🙇‍♂️🙇‍♂️🙇‍♂️
TLS自体の解説はあまりせず作ったコードの解説が主なのでそのへんもご了承ください。コードは以下にあります。

https://github.com/sat0ken/go-tcpip

下準備

ローカルでTLSの動作をお勉強&確認できる環境を作ります。
まずmkcertで証明書を作成します。

$ mkcert my-tls.com localhost 127.0.0.1
$ ls
my-tls.com+2-key.pem  my-tls.com+2.pem

証明書を作成したら、golangでTLS Listenするサーバを立てます。
コードはこちら

tls.Configのexample にあるように Rand をZeroに、KeyLogWriteros.Stdout をセットしておきます。
そうしておくことでServerHelloに含まれるrandomがAll zeroになるのでdebugがしやすくなります。
ただこの設定自体はあくまでdebug用ですから、実際のコードで使ってはいけません。
今回はローカル環境でdebugするだけなのでヨシッ!とします。

またKeyLogWriter指定しておいて、出力された内容をWiresharkeに食わせるとメッセージを復号してくれるので、debug用途でセットしておきます。

TLSクライアントのコードを書いて動作を確認します。
opensslコマンドならこんな感じです。

$ echo | openssl s_client -4 -tls1_2 -cipher AES128-GCM-SHA256 -connect 127.0.0.1:10443

wiresharkでパケットを観察しましょう。
TLSのハンドシェイク自体はいろいろなところで解説があるので割愛します。

ClientHello

パケットを見て流れを理解したらコードを書いていきましょう。
まずClientから送る最初のメッセージとなるClientHelloを作ります。

構造体を定義します。

type ClientHello struct {
	HandshakeType      []byte
	Length             []byte
	Version            []byte
	Random             []byte
	SessionIDLength    []byte
	SessionID          []byte
	CipherSuitesLength []byte
	CipherSuites       []byte
	CompressionLength  []byte
	CompressionMethod  []byte
	Extensions         []byte
}

構造体からClientHelloのメッセージを作る関数を用意します。

func (*ClientHello) NewRSAClientHello() (clientrandom, clienthello []byte) {

	handshake := ClientHello{
		HandshakeType:      []byte{HandshakeTypeClientHello},
		Length:             []byte{0x00, 0x00, 0x00},
		Version:            TLS1_2,
		Random:             noRandomByte(32),
		SessionIDLength:    []byte{0x20},
		SessionID:          noRandomByte(32),
		CipherSuitesLength: []byte{0x00, 0x02},
		// TLS_RSA_WITH_AES_128_GCM_SHA256
		CipherSuites:      []byte{0x00, 0x9c},
		CompressionLength: []byte{0x01},
		CompressionMethod: []byte{0x00},
		Extensions:        setTLSExtenstions(),
	}

	// Typeの1byteとLengthの3byteを合計から引く
	handshake.Length = uintTo3byte(uint32(toByteLen(handshake) - 4))

	var hello []byte
	hello = append(hello, NewTLSRecordHeader("Handshake", toByteLen(handshake))...)
	hello = append(hello, toByteArr(handshake)...)

	return handshake.Random, hello
}

ClientHelloのメッセージには32byteの乱数と、クライアント側で対応しているcipher suitesをセットする必要があります。
乱数にはdebugのため32byteを0で埋めてしまってますが、本来は rand.Read などを使うべきでしょう。

cipher suitesは openssl ciphers -V で出力されるものです。
golangだと tls.CipherSuites で一覧が取れます。
tls.ConfigMinVersionMaxVersion を指定するとそれに合わせてよろしくセットされると思います。
今回はTLS_RSA_WITH_AES_128_GCM_SHA256だけをターゲットにするので、0x9c だけセットしています。

TLSのExtensionはgoのクライアントコードがセットしていたものをWiresharkからそのままセットしています。
セットしている内容は追いきれてませんので説明は割愛させてください。m(--)m

ClientHelloメッセージができたら、Recordヘッダを作成してその後ろにClientHelloメッセージを追加したbyte配列してreturnします。

作成したClientHelloメッセージは connect した socket に write してTLSサーバに送ります。

	syscall.Write(sock, hellobyte)

ServerHello, Certificate, ServerHelloDone

正しくClientHelloメッセージが送れたら、TLSサーバからServerHello, Certificate, ServerHelloDoneが返ってきますのでそれに対応します。
まずServerHelloのなかにServerからの乱数、ServerRandomの32byteが送られてくるのでそれを取り出します。
Certificateではサーバの証明書と公開鍵が含まれているので、証明書の確認と公開鍵を取り出す必要があります。

WriteしたらServerHello, Certificate, ServerHelloDoneをrecvしてbufferをパースする関数に渡します。

	for {
		recvBuf := make([]byte, 1500)
		_, _, err := syscall.Recvfrom(sock, recvBuf, 0)
		if err != nil {
			log.Fatalf("read err : %v", err)
		}
		// ServerHello, Certificates, ServerHelloDoneをパース
		tlsproto, tlsbyte = parseTLSPacket(recvBuf)
		break
	}

parseする関数 ではまず bytes.Split(packet, []byte{0x16, 0x03, 0x03}) を実行してTLSのヘッダでパケットを分割します。
すると[Lengthの2byte+ServerHello, Lengthの2byte+Certificate, Lengthの2byte+ServerHelloDone]という形になるのでそれぞれのHandshakeメッセージをparseしていきます。

func parseTLSPacket(packet []byte) ([]TLSProtocol, []byte) {
	var protocols []TLSProtocol
	var protocolsByte []byte

	// TCPのデータをContentType、TLSバージョンのbyte配列でSplitする
	splitByte := bytes.Split(packet, []byte{0x16, 0x03, 0x03})
	for _, v := range splitByte {
		// 0x16, 0x03, 0x03でsplitするとレコードヘッダのLengthの2byteが先頭となる
		// のでそのLengthとSplitされた配列の長さが合っているか
		if len(v) != 0 && (len(v)-2) == int(binary.BigEndian.Uint16(v[0:2])) {
			rHeader := TLSRecordHeader{
				ContentType:     []byte{0x16},
				ProtocolVersion: []byte{0x03, 0x03},
				Length:          v[0:2],
			}
			tls := parseTLSHandshake(v[2:])
			proto := TLSProtocol{
				RHeader:           rHeader,
				HandshakeProtocol: tls,
			}
			protocolsByte = append(protocolsByte, v[2:]...)
			protocols = append(protocols, proto)
		} else if len(v) != 0 && bytes.Contains(v, []byte{0x00, 0x04, 0x0e}) {
			rHeader := TLSRecordHeader{
				ContentType:     []byte{0x16},
				ProtocolVersion: []byte{0x03, 0x03},
				Length:          v[0:2],
			}
			//ServerHelloDoneの4byteだけ
			tls := parseTLSHandshake(v[2:6])
			proto := TLSProtocol{
				RHeader:           rHeader,
				HandshakeProtocol: tls,
			}
			protocolsByte = append(protocolsByte, v[2:6]...)
			protocols = append(protocols, proto)
		}
	}
	return protocols, protocolsByte
}

Handshakeメッセージをparseする関数 では HandshakeメッセージのTypeに合わせた予め定義しておいた
構造体に値をparseして戻します。

func parseTLSHandshake(packet []byte) interface{} {
	var i interface{}

	switch packet[0] {
	case HandshakeTypeServerHello:
		i = ServerHello{
			HandshakeType:     packet[0:1],
			Length:            packet[1:4],
			Version:           packet[4:6],
			Random:            packet[6:38],
			SessionID:         packet[38:39],
			CipherSuites:      packet[39:41],
			CompressionMethod: packet[41:42],
		}
		fmt.Printf("ServerHello : %+v\n", i)
	case HandshakeTypeServerCertificate:
		i = ServerCertificate{
			HandshakeType:      packet[0:1],
			Length:             packet[1:4],
			CertificatesLength: packet[4:7],
			Certificates:       readCertificates(packet[7:]),
		}
		fmt.Printf("Certificate : %+v\n", i)
	case HandshakeTypeServerKeyExchange:
		i = ServerKeyExchange{
			HandshakeType:               packet[0:1],
			Length:                      packet[1:4],
			ECDiffieHellmanServerParams: unpackECDiffieHellmanParam(packet[4:]),
		}
		fmt.Printf("ServerKeyExchange : %+v\n", i)
	case HandshakeTypeServerHelloDone:
		i = ServerHelloDone{
			HandshakeType: packet[0:1],
			Length:        packet[1:4],
		}
		fmt.Printf("ServerHelloDone : %+v\n", i)
	case HandshakeTypeFinished:
	}

	return i
}

Certificatesメッセージのところではx509証明書をパースしてチェックする関数を呼んでいます。
処理自体はx509パッケージの関数を利用しています。

まずOSに入っている証明書を読み込みます、/etc/ssl/certs/ に入っているものですね。
次にメッセージのbyte配列をx509証明書にパースしたら、証明書を検証します。

問題なければ証明書をreturnします。
ブラウザで証明書エラーが出るときはこの辺の処理に引っかかるわけですね。

func readCertificates(packet []byte) []*x509.Certificate {

	var b []byte
	var certificates []*x509.Certificate

	// https://pkg.go.dev/crypto/x509#SystemCertPool
	// OSにインストールされている証明書を読み込む
	ospool, err := x509.SystemCertPool()
	if err != nil {
		log.Fatalf("get SystemCertPool err : %v\n", err)
	}

	// TLS Handshak protocolのCertificatesのLengthが0になるまでx509証明書をReadする
	// 読み込んだx509証明書を配列に入れる
	for {
		if len(packet) == 0 {
			break
		} else {
			length := sum3BytetoLength(packet[0:3])
			//b := make([]byte, length)
			b = readByteNum(packet, 3, int64(length))
			cert, err := x509.ParseCertificate(b)
			if err != nil {
				log.Fatalf("ParseCertificate error : %s", err)
			}
			certificates = append(certificates, cert)
			//byte配列を縮める
			packet = packet[3+length:]
		}
	}

	// 証明書を検証する
	// 配列にはサーバ証明書、中間証明書の順番で格納されているので中間証明書から検証していくので
	// forloopをdecrementで回す
	for i := len(certificates) - 1; i >= 0; i-- {
		var opts x509.VerifyOptions
		if len(certificates[i].DNSNames) == 0 {
			opts = x509.VerifyOptions{
				Roots: ospool,
			}
		} else {
			opts = x509.VerifyOptions{
				DNSName: certificates[i].DNSNames[0],
				Roots:   ospool,
			}
		}

		// 検証
		_, err = certificates[i].Verify(opts)
		if err != nil {
			log.Fatalf("failed to verify certificate : %v\n", err)
		}
		if 0 < i {
			ospool.AddCert(certificates[1])
		}
	}
	fmt.Println("証明書マジ正しい!")
	return certificates
}

メッセージをパースしたらmainの処理に戻って、ServerHelloからrandomを取り出し、ServerCertificateからServerの公開鍵を取り出して次に進みます。

	var pubkey *rsa.PublicKey
	for _, v := range tlsproto {
		switch proto := v.HandshakeProtocol.(type) {
		case ServerHello:
			// ServerHelloからrandomを取り出す
			tlsinfo.MasterSecretInfo.ServerRandom = proto.Random
		case ServerCertificate:
			_, ok := proto.Certificates[0].PublicKey.(*rsa.PublicKey)
			if !ok {
				log.Fatalf("cast pubkey err : %v\n", ok)
			}
			// Certificateからサーバの公開鍵を取り出す
			pubkey = proto.Certificates[0].PublicKey.(*rsa.PublicKey)
		}
	}

ClientKeyExchange, ChangeCipherspec

サーバからのメッセージを処理したら次にClientKeyExchangeのメッセージを作成するのですが、そもそもこれは何でしょうか?😇😇😇

RSAで実装するのでRFC5246では 7.4.7.1. RSA-Encrypted Premaster Secret メッセージ の部分になります。

結論から言ってしまうと、48byteのpremaster secretを作成したらサーバの公開鍵で暗号化してサーバに送ります。
サーバの秘密鍵でしか復号できないので、サーバからしてみればこのクライアントと通信して大丈夫だねと確認できることになります。

ではpremaster secretって何?となるのですが、これはRFCにこう書かれています。

struct {
    ProtocolVersion client_version;
    opaque random[46];
} PreMasterSecret;

これは2byteのTLSのVersion(0303)と46byteの乱数になります。
乱数はdebug用にall zeroにするとしているため、0303+46byteの0を公開鍵で暗号化したらpremaster secretの出来上がりです。

暗号化したらHandshakeメッセージにして戻します。

func (*ClientKeyExchange) NewClientKeyExchange(pubkey *rsa.PublicKey) (clientKeyExchange, premasterByte []byte) {

	// 46byteのランダムなpremaster secretを生成する
	// https://www.ipa.go.jp/security/rfc/RFC5246-07JA.html#07471
	//premaster := randomByte(46)
	premaster := noRandomByte(46)
	premasterByte = append(premasterByte, TLS1_2...)
	premasterByte = append(premasterByte, premaster...)

	//サーバの公開鍵で暗号化する
	secret, err := rsa.EncryptPKCS1v15(rand.Reader, pubkey, premasterByte)
	if err != nil {
		log.Fatalf("create premaster secret err : %v\n", err)
	}

	clientKey := ClientKeyExchange{
		HandshakeType:                  []byte{HandshakeTypeClientKeyExchange},
		Length:                         []byte{0x00, 0x00, 0x00},
		EncryptedPreMasterSecretLength: uintTo2byte(uint16(len(secret))),
		EncryptedPreMasterSecret:       secret,
	}

	// Lengthをセット
	clientKey.Length = uintTo3byte(uint32(toByteLen(clientKey) - 4))

	// byte配列にする
	clientKeyExchange = append(clientKeyExchange, NewTLSRecordHeader("Handshake", toByteLen(clientKey))...)
	clientKeyExchange = append(clientKeyExchange, toByteArr(clientKey)...)

	return clientKeyExchange, premasterByte
}

次に作るのはChangeCipherspecのメッセージです。
この次から暗号化したメッセージを送りますという合図です。
このメッセージは固定なのでbyteをそのまま返します。

func NewChangeCipherSpec() []byte {
	// Type: handshake, TLS1.2のVersion, length: 2byte, message: 1byte
	return []byte{0x14, 0x03, 0x03, 0x00, 0x01, 0x01}
}

いよいよ次から肝心の暗号化の処理です。

暗号化とFinished Message

クライアントから送られるFinished Messageは最初に暗号化されるメッセージです。
これまでクライアントとサーバ間でやり取りされたHandshakeメッセージ、つまりClientHello, ServerHello, Certifiacate, ClientKeyExchangeを暗号化して送ります。
サーバ側で復号できればこれまでのやり取りは同一クライアントとやり取りされていた、改ざんがなかったことが証明されます。

では実際の暗号化はどう行われるのでしょうか?
まず RFC のFinished Messageの構造体を見てみましょう。

struct {
    opaque verify_data[verify_data_length];
} Finished;
verify_data
   PRF(master_secret, finished_label, Hash(handshake_messages))
      [0..verify_data_length-1];

PRFという関数の引数にmaster_secret, finished_label, Hash(handshake_messages)の結果の12byteがまずFinished Messageになります。
finished_labelは client finished の固定の文字列、 Hash(handshake_messages)はこれまでのHandshakeメッセージをHashしたものです。

まずmaster_secretを作る必要があります。
master_secretの作り方はRFC5246の8.1. Master Secret を計算するに書かれています。

master_secret = PRF(pre_master_secret, "master secret",
                       ClientHello.random + ServerHello.random)
                       [0..47];

ここでもPRFという関数が出てきましたがこれは何でしょうか?
これは疑似乱数関数というもので、RFC5246のHMAC およびその擬似乱数関数 に書かれています。
この関数を使用して任意の長さの疑似乱数を生成します。
PRF関数はP_Hashのラッパー関数と書かれているので、phash関数をreturnしています。

func prf(secret, label, clientServerRandom []byte, prfLength int) []byte {
	var seed []byte
	seed = append(seed, label...)
	seed = append(seed, clientServerRandom...)
	return phash(secret, seed, prfLength)
}

phash関数はtlsパッケージ内のprf.gofunc pHashをそのままコピペして使います。
tlsパッケージのほうは hash.Hashhmac.New に渡していますが、今回はsha256(=TLS_RSA_WITH_AES_128_GCM_SHA256)なので、sha256 を使います。

func phash(secret, seed []byte, prfLength int) []byte {
	result := make([]byte, prfLength)
	mac := hmac.New(sha256.New, secret)
	mac.Write(seed)

	// A(1)
	a := mac.Sum(nil)
	length := 0

	// 必要な長さになるまで計算する
	for length < len(result) {
		mac.Reset()
		mac.Write(a)
		mac.Write(seed)
		b := mac.Sum(nil)
		copy(result[length:], b)
		length += len(b)

		mac.Reset()
		mac.Write(a)
		a = mac.Sum(nil)
	}
	return result
}

このPRF関数に、48byteのpremaster secret, "master secret", ClientRandom+ServerRandom を渡して48byteの長さのmaster secretを作成します。

	var random []byte
	random = append(random, premasterBytes.ClientRandom...)
	random = append(random, premasterBytes.ServerRandom...)
	
	master := prf(premasterBytes.PreMasterSecret, MasterSecretLable, random, 48)

master secretができたらこれをもとに実際に暗号化に使う鍵を生成します。
RFC5246の6.3. 鍵 Calculationに書かれています。

key_block = PRF(SecurityParameters.master_secret,
                   "key expansion",
                   SecurityParameters.server_random +
                   SecurityParameters.client_random);

PRF関数に作成したmaster secret, "key expansion", ServerRandom+ClientRandom を渡して40byteの長さのkeyblockを生成します。

	var keyrandom []byte
	keyrandom = append(keyrandom, premasterBytes.ServerRandom...)
	keyrandom = append(keyrandom, premasterBytes.ClientRandom...)
	
	keyblockbyte := prf(master, KeyLable, keyrandom, 40)

RFCにこう書かれているので、

そして、key_block は、下記のように分割される。:

   client_write_MAC_key[SecurityParameters.mac_key_length]
   server_write_MAC_key[SecurityParameters.mac_key_length]
   client_write_key[SecurityParameters.enc_key_length]
   server_write_key[SecurityParameters.enc_key_length]
   client_write_IV[SecurityParameters.fixed_iv_length]
   server_write_IV[SecurityParameters.fixed_iv_length]

コードでも分割します。

	keyblock := KeyBlock{
		ClientWriteKey: keyblockbyte[0:16],
		ServerWriteKey: keyblockbyte[16:32],
		ClientWriteIV:  keyblockbyte[32:36],
		ServerWriteIV:  keyblockbyte[36:40],
	}

なんで↑のように分割するのかというとRFC 5116 - 認証された暗号化のためのインタフェースとアルゴリズムの定義によるからです。
正直RFC5116を読んでもようわからんので、goの実装を見てみます。
src/crypto/tls/cipher_suites.go に以下のように cihperSuites が定義されています。

var cipherSuites = []*cipherSuite{ // TODO: replace with a map, since the order doesn't matter.
	{TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, 32, 0, 12, ecdheRSAKA, suiteECDHE | suiteTLS12, nil, nil, aeadChaCha20Poly1305},
	{TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, 32, 0, 12, ecdheECDSAKA, suiteECDHE | suiteECSign | suiteTLS12, nil, nil, aeadChaCha20Poly1305},
	{TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, 16, 0, 4, ecdheRSAKA, suiteECDHE | suiteTLS12, nil, nil, aeadAESGCM},
	{TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, 16, 0, 4, ecdheECDSAKA, suiteECDHE | suiteECSign | suiteTLS12, nil, nil, aeadAESGCM},
	{TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, 32, 0, 4, ecdheRSAKA, suiteECDHE | suiteTLS12 | suiteSHA384, nil, nil, aeadAESGCM},
	{TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, 32, 0, 4, ecdheECDSAKA, suiteECDHE | suiteECSign | suiteTLS12 | suiteSHA384, nil, nil, aeadAESGCM},
	{TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256, 16, 32, 16, ecdheRSAKA, suiteECDHE | suiteTLS12, cipherAES, macSHA256, nil},
	{TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, 16, 20, 16, ecdheRSAKA, suiteECDHE, cipherAES, macSHA1, nil},
	{TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256, 16, 32, 16, ecdheECDSAKA, suiteECDHE | suiteECSign | suiteTLS12, cipherAES, macSHA256, nil},
	{TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, 16, 20, 16, ecdheECDSAKA, suiteECDHE | suiteECSign, cipherAES, macSHA1, nil},
	{TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, 32, 20, 16, ecdheRSAKA, suiteECDHE, cipherAES, macSHA1, nil},
	{TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, 32, 20, 16, ecdheECDSAKA, suiteECDHE | suiteECSign, cipherAES, macSHA1, nil},
	{TLS_RSA_WITH_AES_128_GCM_SHA256, 16, 0, 4, rsaKA, suiteTLS12, nil, nil, aeadAESGCM},
	{TLS_RSA_WITH_AES_256_GCM_SHA384, 32, 0, 4, rsaKA, suiteTLS12 | suiteSHA384, nil, nil, aeadAESGCM},
	{TLS_RSA_WITH_AES_128_CBC_SHA256, 16, 32, 16, rsaKA, suiteTLS12, cipherAES, macSHA256, nil},
	{TLS_RSA_WITH_AES_128_CBC_SHA, 16, 20, 16, rsaKA, 0, cipherAES, macSHA1, nil},
	{TLS_RSA_WITH_AES_256_CBC_SHA, 32, 20, 16, rsaKA, 0, cipherAES, macSHA1, nil},
	{TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA, 24, 20, 8, ecdheRSAKA, suiteECDHE, cipher3DES, macSHA1, nil},
	{TLS_RSA_WITH_3DES_EDE_CBC_SHA, 24, 20, 8, rsaKA, 0, cipher3DES, macSHA1, nil},
	{TLS_RSA_WITH_RC4_128_SHA, 16, 20, 0, rsaKA, 0, cipherRC4, macSHA1, nil},
	{TLS_ECDHE_RSA_WITH_RC4_128_SHA, 16, 20, 0, ecdheRSAKA, suiteECDHE, cipherRC4, macSHA1, nil},
	{TLS_ECDHE_ECDSA_WITH_RC4_128_SHA, 16, 20, 0, ecdheECDSAKA, suiteECDHE | suiteECSign, cipherRC4, macSHA1, nil},
}

cipherSuite の型はこうなので、2つ目の数字がkeyの長さ、4つ目の数字がimplicit nonceの長さになります。

// A cipherSuite is a TLS 1.0–1.2 cipher suite, and defines the key exchange
// mechanism, as well as the cipher+MAC pair or the AEAD.
type cipherSuite struct {
	id uint16
	// the lengths, in bytes, of the key material needed for each component.
	keyLen int
	macLen int
	ivLen  int
	ka     func(version uint16) keyAgreement
	// flags is a bitmask of the suite* values, above.
	flags  int
	cipher func(key, iv []byte, isRead bool) any
	mac    func(key []byte) hash.Hash
	aead   func(key, fixedNonce []byte) aead
}

今回使うのは {TLS_RSA_WITH_AES_128_GCM_SHA256, 16, 0, 4, rsaKA, suiteTLS12, nil, nil, aeadAESGCM}, なのでkeyの長さが16byte, maclenが0, implicit nonceが4byteになります。
なのでRFC5246に書かれている鍵計算の分割をコードにすると以下のようになります。

	keyblock := KeyBlock{
		ClientWriteKey: keyblockbyte[0:16],
		ServerWriteKey: keyblockbyte[16:32],
		ClientWriteIV:  keyblockbyte[32:36],
		ServerWriteIV:  keyblockbyte[36:40],
	}

ということで暗号化するときに使う鍵が生成されました。
サーバ側でもClientKeyExchangeで送ったpremaster secretを秘密鍵で復号して、master secretを作り鍵を計算して作ります。
そうするとクライアントとサーバで同じ鍵を持つことになりますので、お互いにデータを暗号、復号できる状態になります。
サーバはクライアントからのメッセージをクライアント側の鍵で復号し、クライアントはサーバからのメッセージをサーバ側の鍵で復号できるわけですね。

それでは実際に暗号化するFinished messageを作成します。
Finished messageはRFCのここに書いてあります。

verify_data
   PRF(master_secret, finished_label, Hash(handshake_messages))
      [0..verify_data_length-1];

PRF関数にmaster_secret, "client finished" という文字列, これまでやり取りしたHandshakeメッセージ(ClientHello, ServerHello, Certificate, ServerHelloDone, ClientKeyExchange)を渡して12byteのverify_dataを作成します。
このverify_dataを送ったらサーバ側でも同じ計算をして一致すれば、これまでのメッセージのやり取りに改ざんがないことが証明されます。

コードではsha265でhashを計算してPRF関数に渡して計算結果を戻します。

	// これまでの全てのhandshake protocolでハッシュを計算する
	hasher := sha256.New()
	hasher.Write(handhake_messages)
	messages := hasher.Sum(nil)

	// 12byteのverify_dataにする
	result := prf(master, labels, messages, 12)

作成したverify_dataの先頭にTLSのFinishedMessageであることを示すタイプとLengthを入れて16byteにしてから、暗号化します。

	finMessage := []byte{HandshakeTypeFinished}
	finMessage = append(finMessage, uintTo3byte(uint32(len(verifyData)))...)
	finMessage = append(finMessage, verifyData...)

暗号化の処理は crypto/cipherNewGCM のExampleをそのまま使います。

func encryptClientMessage(header, plaintext []byte, tlsinfo TLSInfo) []byte {
	
	record_seq := append(header, getNonce(tlsinfo.ClientSequenceNum)...)

	nonce := tlsinfo.KeyBlock.ClientWriteIV
	nonce = append(nonce, getNonce(tlsinfo.ClientSequenceNum)...)

	add := getNonce(tlsinfo.ClientSequenceNum)
	add = append(add, header...)

	block, _ := aes.NewCipher(tlsinfo.KeyBlock.ClientWriteKey)
	aesgcm, _ := cipher.NewGCM(block)

	fmt.Printf("record is %x, nonce is : %x, plaintext is %x, add is %x\n", record_seq, nonce, plaintext, add)
	encryptedMessage := aesgcm.Seal(record_seq, nonce, plaintext, add)
	updatelength := uintTo2byte(uint16(len(encryptedMessage) - 5))
	encryptedMessage[3] = updatelength[0]
	encryptedMessage[4] = updatelength[1]

	fmt.Printf("encrypted data is : %x\n", encryptedMessage)

	return encryptedMessage
}

作成したKeyblockからClientのKeyで NewCipherNewGCM を呼んだら Seal でfinished_messageを暗号化するのですが、このSealに渡している4つの引数は何でしょうか?

  • record_seq
     暗号化したメッセージを後ろにくっつけるTLSのヘッダの5byte+nonceの8byte(all zero)
  • nonce
     先頭の4byteにKeyBlock.ClientWriteIV、後ろの8byteにall zero
  • plaintext
     16byteのfinished message
  • add
     additional_data、RFC5246のここに記載
     seq_numは暗号化したメッセージのシーケンス、今回は初回の往復なので0、次の往復は1とインクリメント
    残りはTLSのヘッダ
additional_data = seq_num + TLSCompressed.type +
                     TLSCompressed.version + TLSCompressed.length;

↑を渡して Seal を呼ぶと45byteのfinished messageが出来上がりますのでこれをClientKeyExchange,ChangeCipherSpecといっしょにサーバに送ります。

	// ClientKeyexchange, ChangeCipehrspec, ClientFinsihedを全部まとめる
	var all []byte
	all = append(all, clientKeyExchangeBytes...)
	all = append(all, changeCipher...)
	all = append(all, encryptFin...)

	syscall.Write(sock, all)

復号化

正しくクライアントからFinishedMessageが送られれば、サーバからChangeCipherSpecとFinishedMessageが返ってきます。
それをrecvして、サーバからのFinishedMessageを復号してチェックする必要があります。

	for {
		recvBuf := make([]byte, 1500)
		_, _, err := syscall.Recvfrom(sock, recvBuf, 0)
		if err != nil {
			log.Fatalf("read err : %v", err)
		}
		// 0byteがChangeCipherSpecであるか
		if bytes.HasPrefix(recvBuf, []byte{HandshakeTypeChangeCipherSpec}) {
			// 6byteからServerFinishedMessageになるのでそれをunpackする
			serverfin := decryptServerMessage(recvBuf[6:51], tlsinfo, ContentTypeHandShake)
			verify := createServerVerifyData(tlsinfo.MasterSecretInfo.MasterSecret, tlsinfo.Handshakemessages)

			if bytes.Equal(serverfin[4:], verify) {
				fmt.Printf("server fin : %x, client verify : %x, verify is ok !!\n", serverfin[4:], verify)
			}
		}
		break
	}

復号には crypto/cipherOpen に nonce, 暗号メッセージ, additional_data を渡して呼びます。
暗号化されている40byteのFinishedMessageですが、その内訳としては先頭の8byteがexplicit nonceで残りの32byteがサーバ側で暗号化されたverify_dataになります。

implicit nonceはkeyblockの計算結果のServerIVの4byteなので、TLSレコードヘッダ以降の8byteを読み取り、合計12byteのnonceとします。
additional_dataの先頭8byteには、TLSのシーケンス番号、Handshakeのタイプ(16)とTLSのVersion(0303)、暗号化される前の平文状態のfinished messageのlengthをセットします。

Overhead()を呼ぶと暗号化されたメッセージと平文の差が求められるので、32-16=16 となり、16を2byteのLength, 0010としてadditional_dataの末尾に入れます。

	// Overhead returns the maximum difference between the lengths of a
	// plaintext and its ciphertext.
	Overhead() int

これでOpenが成功すればサーバのメッセージが復号できます。

func decryptServerMessage(finMessage []byte, tlsinfo TLSInfo, ctype int) []byte {

	header := readByteNum(finMessage, 0, 5)
	ciphertextLength := binary.BigEndian.Uint16(header[3:]) - 8

	seq_nonce := readByteNum(finMessage, 5, 8)
	ciphertext := readByteNum(finMessage, 13, int64(ciphertextLength))

	serverkey := tlsinfo.KeyBlock.ServerWriteKey
	nonce := tlsinfo.KeyBlock.ServerWriteIV
	nonce = append(nonce, seq_nonce...)

	block, _ := aes.NewCipher(serverkey)
	aesgcm, _ := cipher.NewGCM(block)

	var add []byte
	add = getNonce(tlsinfo.ClientSequenceNum)
	add = append(add, byte(ctype))
	add = append(add, TLS1_2...)
	plainLength := len(ciphertext) - aesgcm.Overhead()
	add = append(add, uintTo2byte(uint16(plainLength))...)

	//fmt.Printf("nonce is : %x, ciphertext is %x, add is %x\n", nonce, ciphertext, add)
	plaintext, err := aesgcm.Open(nil, nonce, ciphertext, add)
	if err != nil {
		panic(err.Error())
	}

	return plaintext

}

サーバからのFinishedMessageが復号できたら、クライアント側でも計算して照合しましょう。
クライアントから送ったのFinishedMessageを追加して、"server finished" でPRF関数を呼べば、サーバ側と同じverify_dataになるはずです。
復号できたFinishedMessageとこれが一致すれば正しくTLSハンドシェイクができたことになります。

func createServerVerifyData(master, serverFinMessage []byte) []byte {

	// これまでの全てのhandshake protocolでハッシュを計算する
	hasher := sha256.New()
	hasher.Write(serverFinMessage)
	messages := hasher.Sum(nil)

	result := prf(master, ServerFinishedLabel, messages, 12)

	return result
}

Application Dataの送信

TLSのハンドシェイクが終わったらようやく、ApplicationDataをやり取りできます。
まずはTLSのクライアントとして動作するか試してみます。

appdataをencryptしたらWriteします。サーバでhelloが出たらOKですね。

	appdata := []byte("hello\n")
	encAppdata := encryptClientMessage(NewTLSRecordHeader("AppDada", uint16(len(appdata))), appdata, tlsinfo)
	syscall.Write(sock, encAppdata)

実行するとちゃんとサーバでhelloが出力されました。Yes!!

$ ./tls-server 
2022/04/16 11:13:52 Client From : 127.0.0.1:43582
CLIENT_RANDOM 0000000000000000000000000000000000000000000000000000000000000000 8451e8286417b3545bd637d367cb651bb2426385102ff47cbdce8c7a76aba1140cd944e010e2df2082fc4c3bb81d11e8
message from client : hello

オレオレTLS + オレオレHTTP、その結末は

dockerで起動したHTTPSのNginxサーバに対してやってみましょう。
curlコマンドでHTTPSリクエストを送るのと同じ結果になればいいですね。

$ docker ps
CONTAINER ID   IMAGE        COMMAND                  CREATED       STATUS        PORTS                           NAMES
2b4eb052d45a   nginx:1.21   "/docker-entrypoint.…"   13 days ago   Up 27 hours   80/tcp, 0.0.0.0:8443->443/tcp   nginx
$ cat nginx.conf 
events{
}
http{
    server {
        server_name localhost; 
        listen 443 ssl;
        ssl_certificate /etc/certs/my-tls.pem;
        ssl_certificate_key /etc/certs/my-tls-key.pem; 
        ssl_protocols TLSV1.2 TLSv1.3;
        ssl_prefer_server_ciphers on;
        ssl_ciphers AES128-GCM-SHA256;
        location / {
            root   /usr/share/nginx/html;
            index  index.html index.htm;
        }
    }
}
$ curl https://127.0.0.1:8443
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>

<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>

<p><em>Thank you for using nginx.</em></p>
</body>
</html>

クライアント側のデータを前回作成した、HTTPのGetリクエストのものに置き換えてそれをAppDataとして暗号化したらWriteします。

	req := NewHttpGetRequest("/", fmt.Sprintf("%s:%d", LOCALIP, LOCALPORT))
	reqbyte := req.reqtoByteArr(req)
	encAppdata := encryptClientMessage(NewTLSRecordHeader("AppDada", uint16(len(reqbyte))), reqbyte, tlsinfo)
	syscall.Write(sock, encAppdata)

writeした後にサーバからの応答を復号する受信処理を書いておきます。
先程解説したFinishedMessageの復号をApplicationDataに対して行えばOKです。

受信して正常に復号できたら最後に、TLS接続を終了するAlert, close_notifyを意味するメッセージを送って接続を終了します。

for {
		recvBuf := make([]byte, 1500)
		_, _, err := syscall.Recvfrom(sock, recvBuf, 0)
		if err != nil {
			log.Fatalf("read err : %v", err)
		}
		// 0byteがApplication Dataであるか
		if bytes.HasPrefix(recvBuf, []byte{ContentTypeApplicationData}) {
			// 6byteからServerFinishedMessageになるのでそれをunpackする
			length := binary.BigEndian.Uint16(recvBuf[3:5])
			serverappdata := decryptServerMessage(recvBuf[0:length+5], tlsinfo, ContentTypeApplicationData)
			fmt.Printf("app data from server : %s\n", string(serverappdata))
		}
		break
	}
	tlsinfo.ClientSequenceNum++

	encryptAlert := encryptClientMessage(NewTLSRecordHeader("Alert", 2), []byte{0x01, 0x00}, tlsinfo)
	syscall.Write(sock, encryptAlert)
	time.Sleep(10 * time.Millisecond)
	syscall.Close(sock)

それではこれを実行してみましょう。

$ sudo ./tcpip 
client random : 0000000000000000000000000000000000000000000000000000000000000000
ServerHello : {HandshakeType:[2] Length:[0 0 77] Version:[3 3] Random:[244 93 151 158 151 227 202 120 214 124 74 92 222 102 202 27 135 101 166 7 222 240 96 214 68 79 87 78 71 82 68 1] SessionID:[32] CipherSuites:[215 195] CompressionMethod:[181]}
証明書マジ正しい!
Certificate : {HandshakeType:[11] Length:[0 4 33] CertificatesLength:[0 4 30] Certificates:[0xc000056b00]}
ServerHelloDone : {HandshakeType:[14] Length:[0 0 0]}
ClientRandom : 0000000000000000000000000000000000000000000000000000000000000000
ServerRandom : f45d979e97e3ca78d67c4a5cde66ca1b8765a607def060d6444f574e47524401
CLIENT_RANDOM 0000000000000000000000000000000000000000000000000000000000000000 c460b4d6b24a0d06c863a030a509230e3865c83268214492f9705d376dbdb2e4a8f55afe92d39c11e105d4e1e0cf443d
keyrandom : f45d979e97e3ca78d67c4a5cde66ca1b8765a607def060d6444f574e475244010000000000000000000000000000000000000000000000000000000000000000
ClientWriteIV : 4bf8c7ff
ServerWriteKey : b965fc2b2ac1afbaf199261629dd3b67
ServerWriteIV : cc5d04df
ServerWriteKey : f7e6127d37578507413b5a7968583303
finMessage : 1400000cf3d2b03603d9afb7c00b23c1
record is 16030300100000000000000000, nonce is : 4bf8c7ff0000000000000000, plaintext is 1400000cf3d2b03603d9afb7c00b23c1, add is 00000000000000001603030010
encrypted data is : 1603030028000000000000000023041d0e3562bdd83b5de8aaaacb06f042d07577fe203b07174c258c4b788c15
server fin : b287540ee1bb3a0f877f5ef1, client verify : b287540ee1bb3a0f877f5ef1, verify is ok !!
record is 17030300610000000000000001, nonce is : 4bf8c7ff0000000000000001, plaintext is 474554202f20485454502f312e310d0a486f73743a203132372e302e302e313a383434330d0a557365722d4167656e743a206375726c2f372e36382e300d0a4163636570743a202a2f2a0d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a, add is 00000000000000011703030061
encrypted data is : 17030300790000000000000001cae260a70111c70fa9bdd8ec886f9742b5c60982819036f170a6ad622aa33081a1c465af51a9161217c20c873656a7ded91a51de4f94d38e38fa52b20032347a381fad3fe72657e628daa971060332321d322793e7278b282a6dbe0c69e534edcb4aaef1ba4fbab65c6a72ce7acf79bffd
appdata : 474554202f20485454502f312e310d0a486f73743a203132372e302e302e313a383434330d0a557365722d4167656e743a206375726c2f372e36382e300d0a4163636570743a202a2f2a0d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a
app data from server : HTTP/1.1 200 OK
Server: nginx/1.21.6
Date: Sat, 16 Apr 2022 02:26:29 GMT
Content-Type: text/html
Content-Length: 615
Last-Modified: Tue, 25 Jan 2022 15:03:52 GMT
Connection: close
ETag: "61f01158-267"
Accept-Ranges: bytes

<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>

<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>

<p><em>Thank you for using nginx.</em></p>
</body>
</html>

record is 15030300020000000000000002, nonce is : 4bf8c7ff0000000000000002, plaintext is 0100, add is 00000000000000021503030002
encrypted data is : 150303001a00000000000000028aa9c284c9070ab503026f40052cb0519ff5

debugの出力と重なって見づらいですが、ちゃんとnginxのページが返ってきてるぞおおぉぉぉぉ!!!

終わりに

というわけで完全ではありませんが、TLS1.2プロトコルを作ることができました。
完全ではないという意味ですが、今回はRSAの鍵交換しか対応していません。

プロフェッショナルSSL/TLSに書かれているように、RSAだと秘密鍵が漏れてしまうとそれまでの通信データを
パケットキャプチャして保存しておけば、TLSを全部復号されてしまう可能性があります。

ClientKeyExchangeで送られてるpremaster secretを盗んだ秘密鍵で復号してしまえば、そこからkeyblockを生成してメッセージを復号されてしまうということですね。

そのRSAの弱点をカバーするのがECDHEになります。
またクライアント側の認証、クライアント証明書の送信も今回対応していません。

次はこの辺をカバーしたら、TLS1.3も作ってみますかねー

ということで前回のTCP/IPプロトコルに続き、TLS1.2プロトコルを自作してみることでブラウザがどう通信を暗号化しているかなど少し理解できるようになりました。
改めてプロトコルの理解こそ基礎の理解ですねぇ(しみじみ)

俺たちのプロトコル開発はこれからだ!
次回作にご期待ください。

おまけ

今回発見したTips

opensslコマンドでHTTPリクエスト

$ (echo -ne "GET / HTTP/1.1\r\nHost: localhost:8080\r\nConnection: close\r\n\r\n") | openssl s_client -tls1_2 -quiet -cipher AES128-GCM-SHA256 -connect 127.0.0.1:8443

ワンライナーでTLSサーバを作る

$ ncat -4 -kl --ssl --ssl-cert ./my-tls.pem --ssl-key ./my-tls-key.pem --ssl-ciphers AES128-GCM-SHA256 10443

こうすると簡易HTTPSのAPIサーバとか出来るかな(試してない)

$ (echo -e HTTP/1.1 200 OK\n;echo -e Content-Type: application/json; echo; cat data.json) | ncat -4 -kl --ssl --ssl-cert ./my-tls.pem --ssl-key ./my-tls-key.pem 10443

Discussion