golangで作るTLS1.2プロトコル
はじめに
前回自作で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自体の解説はあまりせず作ったコードの解説が主なのでそのへんもご了承ください。コードは以下にあります。
下準備
ローカルで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に、KeyLogWriter
に os.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.Config
で MinVersion
や MaxVersion
を指定するとそれに合わせてよろしくセットされると思います。
今回は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.goのfunc pHash
をそのままコピペして使います。
tlsパッケージのほうは hash.Hash
を hmac.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/cipher
の NewGCM の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で NewCipher
と NewGCM
を呼んだら 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/cipher
の Open
に 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