【Go】http.Client に独自ルート証明書を追加する

**2024/02/09 追記**

この記事の内容は一部古くなっています。

Go 1.18 から Windows でも `x509.SystemCertPool()` が使用可能となっています。

https://tip.golang.org/doc/go1.18#cryptox509pkgcryptox509

また、現在はルート証明書バンドルが埋め込まれた準公式パッケージが提供されているようです。

https://pkg.go.dev/golang.org/x/crypto/x509roots/fallback

---

次のようなコードになります。
実行するディレクトリに独自ルート証明書ファイルとして `my-cacert.pem` があることを想定しています。

```go
package main

import (
	"crypto/tls"
	"crypto/x509"
	"fmt"
	"log"
	"net/http"
	"os"
)

func main() {
	pem, err := os.ReadFile("my-cacert.pem")
	if err != nil {
		log.Fatal(err)
	}

	caCertPool, err := x509.SystemCertPool()
	if err != nil {
		log.Fatal(err)
	}

	if !caCertPool.AppendCertsFromPEM(pem) {
		log.Fatal("failed to add ca cert")
	}

	defaultTransport, ok := http.DefaultTransport.(*http.Transport)
	if !ok {
		log.Fatal("invalid default transport")
	}

	transport := defaultTransport.Clone()

	transport.TLSClientConfig = &tls.Config{
		RootCAs: caCertPool,
	}

	client := http.Client{
		Transport: transport,
	}

	res, err := client.Get("https://example.com")
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(res.Status)
}
```

基本的には適切なルート証明書プールを設定した [`tls.Config`](https://pkg.go.dev/crypto/tls#Config) を [`http.Transport`](https://pkg.go.dev/net/http#Transport) の `TLSClientConfig` に設定し、それを [`http.Client`](https://pkg.go.dev/net/http#Client) で使ってあげれば OK ですがいくつかポイントがあります。

## `x509.SystemCertPool()` を使う

ルート証明書プールを用意する際、次のように [`x509.NewCertPool()`](https://pkg.go.dev/crypto/x509#NewCertPool) を利用するとシステムのルート証明書を同時に使用することができません。

```go
	pem, err := os.ReadFile("my-cacert.pem")
	if err != nil {
		log.Fatal(err)
	}

	caCertPool := x509.NewCertPool()

	if !caCertPool.AppendCertsFromPEM(pem) {
		log.Fatal("failed to add ca cert")
	}
```

`my-cacert.pem` によって検証できるサーバにのみアクセスするプログラムならこれでも問題ありませんが、それ以外の一般的な証明書によって署名されたサーバにもアクセスする必要がある場合に同じ `http.Client` を使いまわすことができなくなってしまいます。

[`x509.SystemCertPool()`](https://pkg.go.dev/crypto/x509#SystemCertPool) を使用するとシステムが持つルート証明書プール (のコピー) を取得することができます。
ここに独自ルート証明書を追加したものを使用することで、独自ルート証明書が必要なサーバにもそうでないサーバにも同じ `http.Client` を使うことができます。

```go
	caCertPool, err := x509.SystemCertPool()
	if err != nil {
		log.Fatal(err)
	}

	if !caCertPool.AppendCertsFromPEM(pem) {
		log.Fatal("failed to add ca cert")
	}
```

### Windows で実行する場合

Windows で `x509.SystemCertPool()` を実行すると次のようなエラーが発生します。

```
crypto/x509: system root pool is not available on Windows
```

この関数が Windows で使用できない理由は次の issue で知ることができます。

https://github.com/golang/go/issues/16736

https://github.com/golang/go/issues/18609

[#16736](https://github.com/golang/go/issues/16736) での議論により一旦は Windows でも使えるようになりましたが、[#18609](https://github.com/golang/go/issues/18609) での議論によりこの変更が差し戻されています。
Windows の場合は他の OS と異なり、SSL / TLS 接続を行った際に不足しているルート証明書がオンデマンドでダウンロードされるため、`x509.SystemCertPool()` が期待通りに動作しない (必要なルート証明書が不足した証明書プールが返る) ことがあるというのが理由のようです。 

とにかく Windows では `x509.SystemCertPool()` を利用することができないので、Windows でも実行したい場合はもう一工夫必要です。

例えば、Windows の場合は代わりに Mozilla が配布している証明書を利用するという方法があります。

次のコードでは `x509.SystemCertPool()` がエラーだった場合に Mozilla が配布しているルート証明書から証明書プールを生成するようにしています。
[curl 公式から配布されている Mozilla のルート証明書をバンドルした PEM ファイル](https://curl.se/docs/caextract.html)を利用しています。

```go
package main

import (
	"bytes"
	"crypto/sha256"
	"crypto/tls"
	"crypto/x509"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"path"
)

const (
	caCertPEMURL    = "https://curl.se/ca/cacert.pem"
	caCertSHA256URL = "https://curl.se/ca/cacert.pem.sha256"
)

func main() {
	pem, err := os.ReadFile("my-cacert.pem")
	if err != nil {
		log.Fatal(err)
	}

	caCertPool, err := getDefaultCertPool()
	if err != nil {
		log.Fatal(err)
	}

	if !caCertPool.AppendCertsFromPEM(pem) {
		log.Fatal("failed to add ca cert")
	}

	defaultTransport, ok := http.DefaultTransport.(*http.Transport)
	if !ok {
		log.Fatal("invalid default transport")
	}

	transport := defaultTransport.Clone()

	transport.TLSClientConfig = &tls.Config{
		RootCAs: caCertPool,
	}

	client := &http.Client{
		Transport: transport,
	}

	res, err := client.Get("https://example.com")
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(res.Status)
}

func getDefaultCertPool() (*x509.CertPool, error) {
	systemPool, err := x509.SystemCertPool()
	if err == nil {
		return systemPool, nil
	}

	sum, err := getSHA256Sum()
	if err != nil {
		return nil, err
	}

	pem, err := getPEM(sum)
	if err != nil {
		return nil, err
	}

	certPool := x509.NewCertPool()
	if !certPool.AppendCertsFromPEM(pem) {
		return nil, errors.New("failed to append certs from pem")
	}

	return certPool, nil
}

func getSHA256Sum() ([]byte, error) {
	res, err := http.DefaultClient.Get(caCertSHA256URL)
	if err != nil {
		return nil, err
	}

	defer res.Body.Close()

	if res.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("http error, %s", res.Status)
	}

	return io.ReadAll(res.Body)
}

func getPEM(sha256Sum []byte) ([]byte, error) {
	res, err := http.DefaultClient.Get(caCertPEMURL)
	if err != nil {
		return nil, err
	}

	defer res.Body.Close()

	if res.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("http error, %s", res.Status)
	}

	h := sha256.New()
	r := io.TeeReader(res.Body, h)

	pem, err := io.ReadAll(r)
	if err != nil {
		return nil, err
	}

	fileSum := []byte(fmt.Sprintf("%x  %s\n", h.Sum(nil), path.Base(caCertPEMURL)))
	if !bytes.Equal(fileSum, sha256Sum) {
		return nil, errors.New("failed to verify checksum")
	}

	return pem, nil
}
```

`getDefaultCertPool()` を何度も呼ぶ必要がある場合は PEM やチェックサムの内容をある程度キャッシュしておくと良いでしょう。

あるいは、予めコードにルート証明書を埋め込んでおくという方法もあります。
この方法は前述の方法よりも高速ですが定期的に埋め込んだルート証明書を更新する仕組みが必要になります。

ルート証明書を提供する Go パッケージも存在しています。
これらのパッケージも Mozilla から配布された証明書からコードを自動生成しているようです。
利用する場合は Dependabot などによってパッケージを自動更新するようにすると良いでしょう。
また、リポジトリが乗っ取られて不正な証明書を埋め込まれる可能性もゼロではないので、コードに埋め込まれた証明書が妥当なものであるかどうかには常に注意を払っておく必要がありそうです。

https://github.com/gwatts/rootcerts

https://github.com/breml/rootcerts

## `http.Transport` はデフォルト値をコピーしたものを使う

用意したルート証明書プールは [`http.Transport`](https://pkg.go.dev/net/http#Transport) を介して `http.Client` に設定します。

このとき、次のように `http.Transport` を適当に初期化して `TLSClientConfig` のみを設定してしまうと、`http.Transport` のその他のフィールドが全てゼロ値になってしまいます。

```go
	client := &http.Client{
		Transport: &http.Transport{
			TLSClientConfig: &tls.Config{
				RootCAs: caCertPool,
			},
		},
	}
```

`http.Client` の `Transport` が設定されていない場合、`http.DefaultTranport` が使用されます。
これは `http.DefaultClient` と異なり `http.Transport` のゼロ値ではありません。

デフォルトの設定を使いつつ `TLSClientConfig` のみを変更したい場合は `http.Transport` の [`Clone()`](https://pkg.go.dev/net/http#Transport.Clone) メソッドを使用して `http.DefaultTransport` をコピーしたものを使用すると良いでしょう。

ただし `http.DefaultTransport` の型は [`http.RoundTripper`](https://pkg.go.dev/net/http#RoundTripper) なので `Clone()` を使用するには型アサーションが必要です。

```go
	defaultTransport, ok := http.DefaultTransport.(*http.Transport)
	if !ok {
		log.Fatal("invalid default transport")
	}

	transport := defaultTransport.Clone()

	transport.TLSClientConfig = &tls.Config{
		RootCAs: caCertPool,
	}

	client := http.Client{
		Transport: transport,
	}
```

参考:

https://budougumi0617.github.io/2021/09/13/how_to_copy_default_transport/