とてもとても(4ヶ月)遅刻してしまいましたが、この記事はDeNA 25新卒 Advent Calendar 2024の記事です。他の方々の記事もぜひご覧ください!
前回の記事にてC言語でTLSサーバを構築し、そのパケットをキャプチャしてみるといったことをしました。ただ、自分は普段はGo言語のプログラムを書くことが多く、C言語だとどうしても描きにくいなーという気持ちでした。
そこで、今回の記事では、前回の記事と同様のTLSによる暗号化通信を行うクライアント・サーバのプログラムをGo言語で実装し、再度パケットのキャプチャと復号をおこなってみたいと思います。
先にまとめ
サーバのコード
package main
import (
"bufio"
"crypto/tls"
"log"
"net"
)
func main() {
cer, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
log.Fatal("loadkeys: ", err)
}
config := &tls.Config{Certificates: []tls.Certificate{cer}}
ln, err := tls.Listen("tcp", ":12345", config)
if err != nil {
log.Fatal("listen: ", err)
}
defer ln.Close()
log.Println("Start Server:")
for {
conn, err := ln.Accept()
if err != nil {
log.Fatal("accept: ", err)
}
log.Println("New client connection accepted")
go func(conn net.Conn) {
s := bufio.NewScanner(conn)
for s.Scan() {
msg := s.Text()
log.Printf("Accept Message: `%s`", msg)
_, err := conn.Write([]byte(msg))
if err != nil {
log.Print("write: ", err)
}
}
conn.Close()
log.Println("Client connection closed")
}(conn)
}
}
クライアントのコード
package main
import (
"crypto/tls"
"log"
"os"
)
func main() {
w, err := os.Create("keylog.log")
if err != nil {
log.Fatal("open file: ", err)
}
conf := &tls.Config{
InsecureSkipVerify: true, // 動作確認のためにTLSの検証をスキップ
KeyLogWriter: w,
}
conn, err := tls.Dial("tcp", "127.0.0.1:12345", conf)
if err != nil {
log.Fatal("dial: ", err)
}
defer conn.Close()
_, err = conn.Write([]byte("Hello, golang TLS!!\n"))
if err != nil {
log.Fatal("write: ", err)
}
buf := make([]byte, 100)
n, err := conn.Read(buf)
if err != nil {
log.Fatal("read: ", err)
}
log.Printf("Accept Message: `%s`", buf[:n])
}
ソースコードのリポジトリ
実装と動作確認
準備
まずはツールなど準備ですが、Goで実装する場合はほとんど何も必要ありません。「Goの実行環境(Dockerでもローカルでも)」と「tshark」が使えれば問題ありません。Ubuntuの環境ではtsharkはapt経由のインストールと権限の設定が必要でした。こちらは前回の記事をご参照ください。
C言語だと標準のパッケージに加えてOpenSSLのライブラリを用意する必要がありましたが、Goの場合全て標準パッケージで一連の処理が実装できてしまいます。この標準での多機能さがやはり魅力的ですね。
それから、今回もサーバ証明書とサーバの秘密鍵が必要なので、前回と同じ以下のコマンドで作成しておきます。
openssl genrsa 2048 > server.key
openssl req -x509 -new -nodes -key ca.key -subj "/CN=server" -days 10000 -out server.crt
今回は以下のようなフォルダ構成でserver/main.goにサーバの実装を、client/main.goにクライアントの実装を書いていきます。今後のコマンドは、全てgo.modのあるディレクトリで実行していきます。
.
├── .gitignore
├── Makefile
├── go.mod
├── client
│ └── main.go
├── server
│ └── main.go
├── server.crt
└── server.key
サーバの実装
まずはサーバの実装です。以下のgistなどを参考にさせていただきました。
サーバのコード
package main
import (
"bufio"
"crypto/tls"
"log"
"net"
)
func main() {
cer, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
log.Fatal("loadkeys: ", err)
}
config := &tls.Config{Certificates: []tls.Certificate{cer}}
ln, err := tls.Listen("tcp", ":12345", config)
if err != nil {
log.Fatal("listen: ", err)
}
defer ln.Close()
log.Println("Start Server:")
for {
conn, err := ln.Accept()
if err != nil {
log.Fatal("accept: ", err)
}
log.Println("New client connection accepted")
go func(conn net.Conn) {
s := bufio.NewScanner(conn)
for s.Scan() {
msg := s.Text()
log.Printf("Accept Message: `%s`", msg)
_, err := conn.Write([]byte(msg))
if err != nil {
log.Print("write: ", err)
}
}
conn.Close()
log.Println("Client connection closed")
}(conn)
}
}
ぱっと見、C言語での実装と比べて非常にシンプルに済ませることができました。自分がGo言語に慣れていることもあるのでしょうが、標準パッケージのみで実装でき、その処理で何をしているのか分からないという箇所もありませんでした。
まず、以下の箇所では最初に作成したサーバの証明書と秘密鍵のペアを読み込んでいます。
cer, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
log.Fatal("loadkeys: ", err)
}
次に、上で読み込んだキーペアを渡して、*tls.Configのオブジェクトを作り、さらにそれを渡してtls.Listen("tcp", ":12345", config)で特定のポートをリッスン(=サーバを起動)します。
config := &tls.Config{Certificates: []tls.Certificate{cer}}
ln, err := tls.Listen("tcp", ":12345", config)
if err != nil {
log.Fatal("listen: ", err)
}
defer ln.Close()
C言語でバッファの用意などを書いていたのが全く不要です。もちろん、パッケージの内部的な処理として隠蔽されているために見える部分が簡潔になっているだけなのでしょうが、こうして標準パッケージだけでパパッと処理を作れるのは本当に楽だなと感じます。
その後のforループがクライアントの接続を待つループです。ln.Accept()で新しいコネクションを受け付けたら、その後のgoルーチンで受信の処理を行います。
conn, err := ln.Accept()
if err != nil {
log.Fatal("accept: ", err)
}
goルーチンの関数が以下です。
先ほどのln.Accept()の1つ目の戻り値だったnet.Conn型のオブジェクトがio.Readerのインタフェースを実装しているようで、そのままbufio.NewScanner(conn)と渡すことで*bufio.Scannerのオブジェクトが得られました。
そうしたらfor s.Scan() { ... }で入力があるだけループを回し、s.Text()で文字列として読み込んで出力。conn.Write([]byte(msg))でクライアントに同じ文字列を返信します。
go func(conn net.Conn) {
s := bufio.NewScanner(conn)
for s.Scan() {
msg := s.Text()
log.Printf("Accept Message: `%s`", msg)
_, err := conn.Write([]byte(msg))
if err != nil {
log.Print("write: ", err)
}
}
conn.Close()
log.Println("Client connection closed")
}(conn)
バッファの処理などが標準パッケージのbufio.Scannerで処理できてしまっているので、ほんとにシンプルに記述ができました。
クライアントの実装
続いてクライアントの実装です。こちらもサーバの実装と同じく以下のgistを参考にさせていただきました。
クライアントのコード
package main
import (
"crypto/tls"
"log"
"os"
)
func main() {
w, err := os.Create("keylog.log")
if err != nil {
log.Fatal("open file: ", err)
}
conf := &tls.Config{
InsecureSkipVerify: true, // 検証のためにTLSの検証をスキップ
KeyLogWriter: w,
}
conn, err := tls.Dial("tcp", "127.0.0.1:12345", conf)
if err != nil {
log.Fatal("dial: ", err)
}
defer conn.Close()
_, err = conn.Write([]byte("Hello, golang TLS!!\n"))
if err != nil {
log.Fatal("write: ", err)
}
buf := make([]byte, 100)
n, err := conn.Read(buf)
if err != nil {
log.Fatal("read: ", err)
}
log.Printf("Accept Message: `%s`", buf[:n])
}
まずは以下の箇所で*tls.Configのオブジェクトを作ります。InsecureSkipVerify: trueとすることで、TLSの検証をスキップしています。C言語の実装でいうSSL_CTX_set_verify(ctx, SSL_VERIFY_NONE, NULL);にあたるかと思われます。
conf := &tls.Config{
InsecureSkipVerify: true, // 動作確認のためにTLSの検証をスキップ
}
次に以下でサーバに接続し、tls.Connのオブジェクトを受け取ります。
conn, err := tls.Dial("tcp", "127.0.0.1:12345", conf)
if err != nil {
log.Fatal("dial: ", err)
}
defer conn.Close()
確立されたコネクションに対して、以下でサーバにメッセージを送信します。
_, err = conn.Write([]byte("Hello, golang TLS!!\n"))
if err != nil {
log.Fatal("write: ", err)
}
その後、以下でサーバからのレスポンスを受け取ります。理由は後述しますが、ここではbufio.Scannerオブジェクトではなくtls.ConnオブジェクトのReadメソッドを使ってデータを読み込みました。
buf := make([]byte, 100)
n, err := conn.Read(buf)
if err != nil {
log.Fatal("read: ", err)
}
少し詰まったところ:bufio.Scanner.Scan()で処理が詰まる
詰まったところとしてbufio.Scanner.Scan()の取り扱いがありましたので、備忘録も兼ねてここに書いておきます。
bufio.Scannerオブジェクトは改行で区切られた文字列などを読み込むのに適したオブジェクトです。参考にしたgistではbufio.Readerオブジェクトを用いていましたが、挙動の確認のために公式ドキュメントを読んでいてReader.ReadString('\n')を用いるならbufio.Scannerオブジェクトの方が便利だと書かれておりこれを利用するように書き換えました。
ここで注意したいのが、改行区切りで文字列を扱うという部分です。client/main.goのサーバへの送信処理であるconn.Write([]byte("Hello, golang TLS!!\n"))のうち、末尾の\nを消してconn.Write([]byte("Hello, golang TLS!!"))のように送信するとサーバで処理が詰まってしまいました。
完全に挙動を理解した訳ではないのですが、区切り文字(=改行)が来ないためまだデータが来るものと思ってScanner.Scan()の部分を保留しているのかなと考えています(間違っていたら教えてください)。
クライアント側の送信処理は送る文字列に改行を追加することで済みました。今度、サーバ側の処理では、文字列をScanner.Text()で受け取ってそのまま返しています。ただ、ここで受け取る文字列は末尾の区切り文字(=改行)が削除されているようです。
したがって、サーバ側のconn.Write([]byte(msg))で送られる文字列には改行が含まれません。そのため、クライアント側でサーバのレスポンスをScanner.Scan()を使って読もうとするとまたここで処理が詰まってしまいました。
これに対して、サーバ側で送る文字列に改行を付与するという手順もありましたが、今回は色々なパターンを試してもみたいのでクライアント側の受け取り方で対応しました。その結果が先に示したbuf := make([]byte, 100)と n, err := conn.Read(buf)で読み取る形です。バッファを用意してそこにデータを読み込んでいます。
このように、bufio.Scannerを使う際には、区切り文字が必ず含まれているのかという点を気にする必要がありそうでした。
実行
実行は一方のターミナルでgo run ./server/main.goとしてサーバを起動し、別のターミナルでgo run ./client/main.goと実行します。
これで以下のようにサーバ・クライアント双方で文字列が送り合えていれば成功です!
サーバの出力
クライアントの出力
パケットの確認とキーログファイルの保存
パケットの確認は前回のC言語のときにも行いましたので、今回はキーログファイルの設定までまとめて行ってしまいます。
キーログファイルの保存設定は、まずクライアントのプログラムで保存先のファイルの*os.Fileオブジェクトを取得しておき、*tls.Configオブジェクトの設定項目としてそのポインターを渡します。これだけで設定が済んでしまうので、本当にシンプルですね。
func main() {
+ w, err := os.Create("keylog.log")
+ if err != nil {
+ log.Fatal("open file: ", err)
+ }
conf := &tls.Config{
InsecureSkipVerify: true, // 検証のためにTLSの検証をスキップ
+ KeyLogWriter: w,
}
これでクライアントを実行すると、keylog.logファイルが保存されるようになりました。
あとはC言語のときと同じように、tshark -i any -w out.pcap -f "port 12345"と実行してサーバのポートを監視しておき、クライアントの処理を実行してout.pcapも取得します。
暗号化通信の復号
何もせずにout.pcapをWiresharkで開いてみると、案の定、通信の内容は暗号化されていて読めないかと思います。
複合の手順はC言語の際と全く同じです。上部タブから「編集」→「設定」→「Protocols」→「TLS」と選んでいき、この設定の中で「(Pre)-Master-Secret log filename」という所に先ほどのkeylog.logを設定します。
これで通信を復号すると、以下の通り今回もやり取りしている通信の内容を見ることができました!
まとめ
この記事では先日C言語で実装したTLSサーバをGo言語で実装していきました。全体的な所感としては、Go言語だとやはりシンプルかつ手軽に処理が書けるなといったことを感じています。ざっくりとコード量はC言語のときの4分の1程度になっていました。
ただ、本文中でも少し触れましたが、こうしてシンプルに手軽に書けるのは、標準パッケージが充実していてその内部で実装を済ませてくれているからであり、本当の意味で処理を理解しようとすると、苦しみながらもC言語で低レベルから書いた方が良いのかなとも思いました。今回の記事のbufio.Scannerで気を付ける必要があったことにも関連しますが、Goで手軽に書くにも、その背後で起こっている処理や動作原理をしっかり把握し理解した上で実装を行う必要があるなと改めて感じています。
参考文献



