🍻

golangで作るTLS1.2プロトコル(ECDHE&クライアント認証編)

2022/04/22に公開

はじめに

前回TLS1.2プロトコルスタックを自作してみましたが、実装が及んでない部分がありました。
1つは鍵交換がRSAだけになっているのともう1つはクライアント認証に対応していないところです。

RSAではその仕組み上セキュリティ的に脆弱な点がありますし、サーバからクライアント認証を求められたら対応できませんので機能追加を行います。

まずはECDHE鍵交換の対応から行います。

ECHDE鍵交換

前回の記事でも書きましたがRSAでは毎回同じ公開鍵でpremaster secretを暗号化するため、秘密鍵が一旦漏れてしまうとそれまでの通信が全て復号される可能性があります。
このRSAの弱点を回避する鍵交換の仕組みがDiffie-Hellman鍵交換(DH)や楕円曲線Diffie-Hellman鍵交換(ECDHE)というものです。

仕組みや計算式、どうして第3者が解くことができないのかなどは僕も解説できませんので、WikiRFC 7748 - Elliptic Curves for Security,プロフェッショナルTLS/SSLなどを読んでみてください。

アリスとボブが楕円曲線を使ってやり取りすると第三者が見れるのは公開鍵だけで秘密鍵は知ることができないからお手上げらしい、まぁそういうもんなんだねと僕は理解しておきます。
いったん論より証拠、サンプルコードで試してみましょう。

package main

import (
	"crypto/rand"
	"fmt"
	"golang.org/x/crypto/curve25519"
)

func randomByte(num int) []byte {
	b := make([]byte, num)
	rand.Read(b)
	return b
}

func main() {
	// aliceとbobが秘密鍵を作る
	alice_privateKey := randomByte(curve25519.ScalarSize)
	bob_privateKey := randomByte(curve25519.ScalarSize)

	// aliceとbobが公開鍵を作る
	alice_publicKey, _ := curve25519.X25519(alice_privateKey, curve25519.Basepoint)
	bob_publicKey, _ := curve25519.X25519(bob_privateKey, curve25519.Basepoint)

	// 楕円曲線暗号のスカラー倍算=楕円曲線上で掛け算をする
	// https://ja.wikipedia.org/wiki/%E6%A5%95%E5%86%86%E6%9B%B2%E7%B7%9A%E6%9A%97%E5%8F%B7#Scalar_Multiplication
	curve25519.ScalarBaseMult((*[32]byte)(alice_publicKey), (*[32]byte)(alice_privateKey))
	curve25519.ScalarBaseMult((*[32]byte)(bob_publicKey), (*[32]byte)(bob_privateKey))

	// ECDHEの鍵交換をする
	a, _ := curve25519.X25519(alice_privateKey, bob_publicKey)
	b, _ := curve25519.X25519(bob_privateKey, alice_publicKey)

	fmt.Printf("bob's Shared key is   %x\n", a)
	fmt.Printf("alice's Shared key is   %x\n", b)
}

↑のようなコードを書いたらこれを実行します。

$ go run ecdhe.go 
alice's Shared key is   b4865f485a5aebc085aba5089010c953034648a400fb454e68bc6f7369c1fd15
bob's Shared key is    b4865f485a5aebc085aba5089010c953034648a400fb454e68bc6f7369c1fd15

出力を見ればわかるようにaliceとbobの間で同じ文字列を得ることができました。
aliceはbobからbob_publicKeyを受け取って計算を行い、bobはaliceからalice_publicKeyを受け取って計算を行うと同じ値を得ることができます。

aliceをTLSクライアント、bobをTLSサーバとしましょう。
aliceとbobは予め秘密鍵と公開鍵を作成しておきます。bobはServerKeyExchangeでbob_publicKeyを送り、aliceはClientKeyExchangeでalice_publicKeyを送ります。
互いに公開鍵を交換した後に計算すると、両者は同じ値を得ます。

この同じ値をpremaster secretとして使います。
master secretを作成してKeyblockを作るとaliceとbobは同じ共通鍵を得ることになるので、お互いに暗号と復号を行えることになります。

楕円曲線暗号のタイプ

楕円曲線暗号にはいくつかのタイプがあります。↑のサンプルではcurve25519=x25519というのになります。

goのtlsパッケージではcommon.goのconstで以下の4つが定義されています。

const (
	CurveP256 CurveID = 23
	CurveP384 CurveID = 24
	CurveP521 CurveID = 25
	X25519    CurveID = 29
)

この4つはRFC8422の5.1.1. Supported Elliptic Curves Extensionに従っています。

ユーザは tls.ConfigCurvePreferences []CurveID で使いたい楕円曲線暗号を指定することができます。

今回の実装ではx25519しか対応しませんが、P256を使いたい時は、crypto/ellipticfunc GenerateKey の引数でP256を指定します。
以下のページにサンプルが乗ってるので興味がある方は見てみてください。

https://billatnapier.medium.com/little-protects-you-on-line-like-ecdh-lets-go-create-it-a14188eabded

ECHDE鍵交換の実装

さて下調べは出来たので実際に実装していきましょう。
前回のコードに追加した部分を説明していきます。

まずServerKeyExchangeのパケットを分解して入れる用の構造体を定義します。

type ServerKeyExchange struct {
	HandshakeType               []byte
	Length                      []byte
	ECDiffieHellmanServerParams ECDiffieHellmanParam
}

type ECDiffieHellmanParam struct {
	CurveType          []byte
	NamedCurve         []byte
	PubkeyLength       []byte
	Pubkey             []byte
	SignatureAlgorithm []byte
	SignatureLength    []byte
	Signature          []byte
}

Handshakeメッセージをparseする関数にServerKeyExchangeのcaseを追加しました。

	case HandshakeTypeServerKeyExchange:
		i = ServerKeyExchange{
			HandshakeType:               packet[0:1],
			Length:                      packet[1:4],
			ECDiffieHellmanServerParams: unpackECDiffieHellmanParam(packet[4:]),
		}
		fmt.Printf("ServerKeyExchange : %+v\n", i)

parseしたHandshakeメッセージを処理するfor文にServerKeyExchangeのcaseを追加しました。
RSAのときは1と2まで処理すればよかったのですが、3が追加になるからですね。

  1. ServerHelloからServerRandomを取り出す
  2. Certificatesから公開鍵を取り出す
  3. ServerKeyExchangeから公開鍵を取り出す ←New!!
		case ServerKeyExchange:
			if proto.ECDiffieHellmanServerParams.NamedCurve[1] == CurveIDx25519 {
				// サーバの公開鍵でECDHEの鍵交換を行う
				tlsinfo.ECDHEKeys = genrateECDHESharedKey(proto.ECDiffieHellmanServerParams.Pubkey)
				// premaster secretに共通鍵をセット
				tlsinfo.MasterSecretInfo.PreMasterSecret = tlsinfo.ECDHEKeys.sharedKey
			}

ServerKeyExchangeを処理するときに、サーバにClientKeyExchangeで送る公開鍵を生成しています。
予め生成しておくとさっき書いてましたがもらってから生成してますよねw、まぁいいでしょう。

計算したら結果を全て構造体に入れてmainに戻します。

func genrateECDHESharedKey(serverPublicKey []byte) ECDHEKeys {
	// 秘密鍵となる32byteの乱数をセット
	clientPrivateKey := randomByte(curve25519.ScalarSize)
	// ClientKeyExchangeでサーバに送る公開鍵を生成
	clientPublicKey, _ := curve25519.X25519(clientPrivateKey, curve25519.Basepoint)

	// サーバの公開鍵と鍵交換をする
	clientSharedKey, _ := curve25519.X25519(clientPrivateKey, serverPublicKey)
	fmt.Printf("gen public key is : %x\n", clientPublicKey)
	fmt.Printf("gen shared key is : %x\n", clientSharedKey)

	return ECDHEKeys{
		privateKey: clientPrivateKey,
		publicKey:  clientPublicKey,
		sharedKey:  clientSharedKey,
	}
}

mainに戻ったら以降の処理は前回と同じなので説明は割愛します。
それでは動作確認してみましょう、TLSサーバのコードはこれです。
サーバから world とメッセージが返ってくればOKです。

ではクライアントのコードを実行します。

$ sudo ./tcpip 
client random : 0000000000000000000000000000000000000000000000000000000000000000
ServerHello : {HandshakeType:[2] Length:[0 0 51] Version:[3 3] Random:[0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0] SessionID:[0] CipherSuites:[192 47] CompressionMethod:[0]}
証明書マジ正しい!

省略

record is 17030300060000000000000001, nonce is : 7355b78d0000000000000001, plaintext is 68656c6c6f0a, add is 00000000000000011703030006
encrypted data is : 170303001e00000000000000018ca54ef306ec5d1ebaedbc15e290ba5943c108c210ef
app data from server : world

record is 15030300020000000000000002, nonce is : 7355b78d0000000000000002, plaintext is 0100, add is 00000000000000021503030002
encrypted data is : 150303001a000000000000000292ba9afe8b0c77bcb941887d1f07e9f578b3

ApplicationDataが返ってきていて world の値が出ていますね。
Wiresharkeで見てもちゃんとECDHEでTLSハンドシェイクを行えてますね。

nginxからもちゃんとコンテンツが返ってきたので、ECHDEは実装できました。

$ sudo ./tcpip 
client random : 0000000000000000000000000000000000000000000000000000000000000000
ServerHello : {HandshakeType:[2] Length:[0 0 85] Version:[3 3] Random:[199 93 164 44 56 225 22 188 122 211 37 98 218 211 23 109 83 250 195 170 210 15 21 116 68 79 87 78 71 82 68 1] SessionID:[32] CipherSuites:[147 164] CompressionMethod:[227]}
証明書マジ正しい!
Certificate : {HandshakeType:[11] Length:[0 4 33] CertificatesLength:[0 4 30] Certificates:[0xc0004c1700]}
ServerKeyExchange : {HandshakeType:[12] Length:[0 1 40] ECDiffieHellmanServerParams:{CurveType:[3] NamedCurve:[0 29] PubkeyLength:[32] Pubkey:[1 244 52 13 71 248 80 200 64 234 80 199 133 222 28 241 102 30 73 8 198 249 58 65 194 153 164 135 152 42 173 33] SignatureAlgorithm:[8 4] SignatureLength:[1 0] Signature:[85 16 2 251 30 41 124 201 117 191 21 130 76 0 106 149 229 212 162 92 1 230 56 114 232 4 140 107 134 7 114 236 79 195 65 147 114 28 63 47 185 194 193 72 218 193 36 30 63 127 112 153 102 95 245 62 9 68 206 162 26 183 212 80 6 248 108 112 48 213 91 177 212 207 229 26 158 95 237 245 71 6 233 205 0 151 214 160 171 47 115 220 79 227 48 178 82 31 246 101 227 13 115 22 128 116 42 104 147 171 151 166 57 55 165 208 124 127 165 233 131 178 132 60 19 14 80 109 65 177 136 25 194 141 93 56 62 113 66 200 71 247 55 16 107 52 61 216 44 202 38 79 96 217 47 4 3 223 126 253 99 53 84 95 238 100 55 83 220 23 70 181 177 39 126 87 171 39 114 28 161 151 200 76 171 40 212 132 206 213 104 135 252 14 208 160 133 203 78 129 235 88 141 246 35 78 44 114 212 123 178 114 146 104 116 117 162 171 101 121 210 205 180 87 238 46 235 138 173 41 168 199 156 80 245 6 150 54 177 48 250 204 155 124 61 250 46 30 107 136 156 180 240 199 131 91]}}
ServerHelloDone : {HandshakeType:[14] Length:[0 0 0]}
gen public key is : 64ffccce5bedf41c0d1fda2ab6e2f464ff0e5b57e804159f13c47a9d2accfe79
gen shared key is : 7d94b3dca73fa635c22763cd7690a93556c6c168b23925c637b77fbc9aeb666c
ClientRandom : 0000000000000000000000000000000000000000000000000000000000000000
ServerRandom : c75da42c38e116bc7ad32562dad3176d53fac3aad20f1574444f574e47524401
CLIENT_RANDOM 0000000000000000000000000000000000000000000000000000000000000000 41573d54099fe228ef503b0f313dd46c28478e4743b90cb5891db652faf26abd07997547d3ae9fe86f3877e3710057d3
keyrandom : c75da42c38e116bc7ad32562dad3176d53fac3aad20f1574444f574e475244010000000000000000000000000000000000000000000000000000000000000000
ClientWriteIV : f1eb4d21
ServerWriteKey : fb8989f20719c805bc8d6fffa9f72d20
ServerWriteIV : 85cc9256
ServerWriteKey : 55d0310abb38cdb377633e853ccbe2a5
finMessage : 1400000c43fcef5ff94203c3406d4acb
record is 16030300100000000000000000, nonce is : f1eb4d210000000000000000, plaintext is 1400000c43fcef5ff94203c3406d4acb, add is 00000000000000001603030010
encrypted data is : 160303002800000000000000005484059c4a6aa667374e9b48418b1c8121e9878525a989b08cc8f841d0fe8de3
server fin : 9228f7317560f15ffc5eb824, client verify : 9228f7317560f15ffc5eb824, verify is ok !!
record is 17030300610000000000000001, nonce is : f1eb4d210000000000000001, plaintext is 474554202f20485454502f312e310d0a486f73743a203132372e302e302e313a383434330d0a557365722d4167656e743a206375726c2f372e36382e300d0a4163636570743a202a2f2a0d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a, add is 00000000000000011703030061
encrypted data is : 17030300790000000000000001e6dea84264923897aacd87fa1c5cd4f07740445f29430402344348960f4b68cdf07ea84a73e8843c4a112670e25f5067722b3812798dd97e4fbd4baf081b936655d77aeea85ae7010980e432bd2949f3f88e99da63145097112f40afd634a9227d288d5bd7a4de3ce64e37d0edc4ac02a5
app data from server : HTTP/1.1 200 OK
Server: nginx/1.21.6
Date: Tue, 19 Apr 2022 11:42:11 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 : f1eb4d210000000000000002, plaintext is 0100, add is 00000000000000021503030002
encrypted data is : 150303001a0000000000000002b70be4be3184cfb394f1ec8a5a43cbd91dbb

TLSクライアント認証

次にTLSのクライアント認証を実装してみます。
これまではサーバの証明書だけ検証していましたが、クライアントの証明書をサーバに送って検証されることになります。
お酒を買うときに身分証を提示するみたいな感じですよね(違う)

とりあえず実験環境を整えるために、mkcertでクライアント証明書を作ります。

$ mkcert -client my-tls.com localhost 127.0.0.1

TLSサーバでは tls.ConfigClientAuth: tls.RequireAndVerifyClientCert をセットします。
クライアントでは tls.LoadX509KeyPair で読み込んだのを tls.ConfigCertificates にセットします。

このクライアントとサーバを実行して、Wiresharkeで見てみると、サーバからCertifiacateRequestが送られるようになりました。
クライアントはそれに対応するため、ClientKeyExchangeの前にCertifiacate、後にCertifiacateVerifyを入れてからChangeCipherSpec、FinishedMessageと続いてます。

では実装していきましょう。
追加部分を解説していきます。

まずサーバからのCertifiacateRequestを読み取る処理を追加します。
構造体を定義しておいて、

type CertificateRequest struct {
	HandshakeType                 []byte
	Length                        []byte
	CertificateTypesCount         []byte
	CertificateTypes              []byte
	SignatureHashAlgorithmsLength []byte
	SignatureHashAlgorithms       []byte
}

サーバのHandshakeメッセージをparseするところに追加します。

	case HandshakeTypeCertificateRequest:
		i = CertificateRequest{
			HandshakeType:                 packet[0:1],
			Length:                        packet[1:4],
			CertificateTypesCount:         packet[4:5],
			CertificateTypes:              packet[5:7],
			SignatureHashAlgorithmsLength: packet[7:9],
			SignatureHashAlgorithms:       packet[9:],
		}

次にクライアントからCertifiacate, CertificateVerifyを送れるようにします。
クライアント証明書を送る構造体は、サーバから送られてるくるものと同じです。
RFCに以下書いてあります。

クライアント証明書は、7.4.2 節に定義されている証明書の構造体を使って送られる。

tls.LoadX509KeyPair でクライアント証明書と鍵を読みこんでおいた結果をCertifiacateメッセージを作成する関数に渡します。

func (*ClientCertificate) NewClientCertificate(cert tls.Certificate) (clientCertBytes []byte) {

	clientCert := ClientCertificate{
		HandshakeType:      []byte{HandshakeTypeCertificate},
		Length:             []byte{0x00, 0x00, 0x00},
		CertificatesLength: uintTo3byte(uint32(len(cert.Certificate[0]) + 3)),
		CertificateLength:  uintTo3byte(uint32(len(cert.Certificate[0]))),
		Certificate:        cert.Certificate[0],
	}

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

	// TLSレコードヘッダを入れてbyte配列にする
	clientCertBytes = append(clientCertBytes, NewTLSRecordHeader("Handshake", toByteLen(clientCert))...)
	clientCertBytes = append(clientCertBytes, toByteArr(clientCert)...)

	return clientCertBytes
}

次にCertifiacateVerifyメッセージです。
RFCはここです。

ここで何をすればよいのかというと、ここまでやり取りされたHandshakeメッセージをクライアントの秘密鍵で署名してサーバに送ります。
サーバは受信したクライアント側証明書の中にある公開鍵で署名を検証し、成功すれば無事クライアント認証が完了です。

tlsパッケージでは auth.goverifyHandshakeSignatureで署名を検証しています。

↑という仕組みなのでClientHelloからClientKeyExchangeまでのメッセージをsha256でハッシュしてから秘密鍵で署名してCertificateVerifyを作る関数を定義します。

func (*CertificateVerify) NewCertificateVerify(certs tls.Certificate, handshake_messages []byte) (certVerifyBytes []byte) {

	hasher := sha256.New()
	hasher.Write(handshake_messages)
	messages := hasher.Sum(nil)

	fmt.Printf("messages sum is %x\n", messages)

	rsaPrivatekey := certs.PrivateKey.(*rsa.PrivateKey)
	signOpts := &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash}
	signature, err := rsa.SignPSS(rand.Reader, rsaPrivatekey, crypto.SHA256, messages, signOpts)
	if err != nil {
		log.Fatal(err)
	}

	verify := CertificateVerify{
		HandshakeType: []byte{HandshakeTypeCertificateVerify},
		Length:        []byte{0x00, 0x00, 0x00},
		// rsa_pss_rsae_sha256 = 0804
		SignatureHashAlgorithms: []byte{0x08, 0x04},
		// SHA256なのでLengthは256
		SignatureLength: []byte{0x01, 0x00},
		Signature:       signature,
	}

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

	// TLSレコードヘッダを入れてbyte配列にする
	certVerifyBytes = append(certVerifyBytes, NewTLSRecordHeader("Handshake", toByteLen(verify))...)
	certVerifyBytes = append(certVerifyBytes, toByteArr(verify)...)

	return certVerifyBytes
}

CertificateとCertifiacateVerifyメッセージが作れたらひとまとめにしてサーバにWriteします。
サーバからChangeCipherSpecとFinishedが正しく送られてくれば、クライアント認証が正しく出来ています。

	// 全部まとめる
	var all []byte
	all = append(all, clientCertMessageBytes...)
	all = append(all, clientKeyExchangeBytes...)
	all = append(all, certVerifyBytes...)
	all = append(all, changeCipher...)
	all = append(all, encryptFin...)

	syscall.Write(sock, all)

では確認してみましょう。
サーバからデータが返ってきてますね、YES!!

$ sudo ./tcpip 
client random : 0000000000000000000000000000000000000000000000000000000000000000
ServerHello : {HandshakeType:[2] Length:[0 0 51] Version:[3 3] Random:[0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0] SessionID:[0] CipherSuites:[192 47] CompressionMethod:[0]}
証明書マジ正しい!

省略

encrypted data is : 170303001e00000000000000018ca54ef306ec5d1ebaedbc15e290ba5943c108c210ef
app data from server : world

record is 15030300020000000000000002, nonce is : 7355b78d0000000000000002, plaintext is 0100, add is 00000000000000021503030002
encrypted data is : 150303001a000000000000000292ba9afe8b0c77bcb941887d1f07e9f578b3

Wiresharkで見ても正しくTLSのクライアント認証を経てからApplicationDataがやり取りされています。
nginxは設定が面倒くさいので省略ww😇😇😇

おわりに

というわけで足りてない箇所も多々あると思いますが、TLS1.2プロトコルスタックを自分で実装してみました。

こういう車輪の再開発で自分で作ったものって実用できるものでは全くないのですけども、実装する過程で得られた知識っていうのは自分の財産になりますよね😊😊😊

WiresharkeとRFC、tlsパッケージの実装を毎日にらめっこしていると段々わかるようになりましたし、これをやる前よりTLSについて自分で説明できるようになったかなと思います。
完全に理解したとは言えませんがw

というわけで次はTLS1.3を作りましょうか、俺達のプロトコルスタック開発はこれからだ!
satokenの次回作にご期待ください終

Discussion