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.Config
を http.Transport
の TLSClientConfig
に設定し、それを 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.Client
の Transport
が設定されていない場合、http.DefaultTranport
が使用されます。
これは http.DefaultClient
と異なり http.Transport
のゼロ値ではありません。
デフォルトの設定を使いつつ TLSClientConfig
のみを変更したい場合は http.Transport
の Clone()
メソッドを使用して 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,
}
参考: