LoginSignup
3
2

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

Last updated at Posted at 2022-01-06

2024/02/09 追記

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

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

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


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

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.Confighttp.TransportTLSClientConfig に設定し、それを http.Client で使ってあげれば OK ですがいくつかポイントがあります。

x509.SystemCertPool() を使う

ルート証明書プールを用意する際、次のように x509.NewCertPool() を利用するとシステムのルート証明書を同時に使用することができません。

	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() を使用するとシステムが持つルート証明書プール (のコピー) を取得することができます。
ここに独自ルート証明書を追加したものを使用することで、独自ルート証明書が必要なサーバにもそうでないサーバにも同じ http.Client を使うことができます。

	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 で知ることができます。

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

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

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

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

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 などによってパッケージを自動更新するようにすると良いでしょう。
また、リポジトリが乗っ取られて不正な証明書を埋め込まれる可能性もゼロではないので、コードに埋め込まれた証明書が妥当なものであるかどうかには常に注意を払っておく必要がありそうです。

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

用意したルート証明書プールは http.Transport を介して http.Client に設定します。

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

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

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

デフォルトの設定を使いつつ TLSClientConfig のみを変更したい場合は http.TransportClone() メソッドを使用して http.DefaultTransport をコピーしたものを使用すると良いでしょう。

ただし http.DefaultTransport の型は http.RoundTripper なので Clone() を使用するには型アサーションが必要です。

	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,
	}

参考:

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2