詩と創作・思索のひろば

ドキドキギュンギュンダイアリーです!!!

中間証明書のないサーバにアクセスする: 言語別の実装

前回の記事のつづきです。

中間証明書のないサーバにアクセスする - 詩と創作・思索のひろば

さて、この方法で信頼できる中間証明書を手元に集めることができたので、これをプログラムから利用するにはどうしたらよいのか、ということを見ていく。

実験したいのはこういうことである:

  • 信頼できる中間証明書を手元に置いておくことで、中間証明書がないが本来は信頼できるサイトにアクセスできる。
    • 欠けている中間証明書を含めたチェーンで、サイトの証明書を検証できる。
  • 信頼できる中間証明書を手元に置いても、普通のサイトが正しく取得できる。
    • システムがもともと持っている証明書を壊したりしていない。
  • 信頼できない中間証明書を手元に置いてしまっても、本来信頼できないサイトにはアクセスできない。
    • 雑に中間証明書を持っておいて、実行時にそれらの中間証明書まで含めて検証する、というアプローチが取れるか? という実験。
    • 自己署名ルートによって署名された中間証明書を手元に置いて、その中間証明書によって署名されたサイトにアクセスしても、そのルート証明書を信頼していないので失敗する。
    • ついでに、中間証明書だけで検証してしまう(ルートまでたどらない)方法も検討しておく。

言語は openssl コマンド(言語じゃないけど)、Perl、Go、Node。まあ、よく使ってるやつです。

リポジトリは https://github.com/motemen/sketch-interm-certs 。README に書いてあるとおりに進めれば同じ手順で確認できます。以下、各言語の様子を見ていく。

  • 自己署名ルート以下は cfssl で作っている。これがとにかく楽。
  • 信頼できる中間証明書と、信頼できない中間証明書をまとめて /opt/intermediate-certs/ca-bundle.crt というファイルにしている。
  • システムの信頼する証明書は /etc/ssl/certs ディレクトリに保管されている。

openssl コマンド

https://github.com/motemen/sketch-interm-certs/blob/b896e1f/tests/openssl.sh

openssl s_client でテストする。中間証明書バンドルを -CAfile で指定するが、そうするとシステムの証明書を使わなくなってしまうので、あわせて -CApath /etc/ssl/certs する必要がある。

また、-partial_chain を指定することで中間証明書だけでサイトを検証できるようになる(チェーンをルートまでたどらない)。

Perl

https://github.com/motemen/sketch-interm-certs/blob/b896e1f/tests/perl.t

LWP::UserAgent を利用する。ssl_opts オプションに SSL_ca_fileSSL_ca_path を指定する。openssl の場合と似てますね。内部で OpenSSL 使ってるので当然といえば当然か。

これらはそれぞれ PERL_LWP_SSL_CA_FILEPERL_LWP_SSL_CA_PATH 環境変数で指定できるらしい。けど普通はオープンインターネット向けのエージェント以外にも内部エンドポイント向けのエージェントも作りたいわけで、そうなるとデフォルト値をゆるめに運用はしたくないと思うので、検証はしてない(以下に書いている事情もある)。

my $ua_with_interm_partial_chain = LWP::UserAgent->new(
    ssl_opts => {
        SSL_ca_file => '/opt/intermediate-certs/ca-bundle.crt',
        SSL_ca_path => '/etc/ssl/certs',
    }
);

ただし、気をつけなければならないのは、デフォルトでは -partial_chain が指定されたかのようなふるまいをすること。手元に信頼できないルートによって署名された証明書を持っている場合、その証明書を信頼することになっている。これは openssl s_client と違う挙動。

どういう理由かはよくわからないんだけど、IO::Socket::SSL の中でそういうことをしている

これを回避するには、ssl_opts にコールバックを指定して、OpenSSL の API を呼んでやる。

my $ua_with_interm_full_chain = LWP::UserAgent->new(
    ssl_opts => {
        SSL_ca_file => '/opt/intermediate-certs/ca-bundle.crt',
        SSL_ca_path => '/etc/ssl/certs',
        SSL_create_ctx_callback => sub {
            my $ctx = shift;
            my $param = Net::SSLeay::CTX_get0_param($ctx);
            my $rv = Net::SSLeay::X509_VERIFY_PARAM_get_flags($param);
            Net::SSLeay::X509_VERIFY_PARAM_clear_flags($param, Net::SSLeay::X509_V_FLAG_PARTIAL_CHAIN());
            Net::SSLeay::CTX_set1_param($ctx, $param);
        },
    }
);

Go

https://github.com/motemen/sketch-interm-certs/blob/b896e1f/tests/go_test.go

Go は TLS の実装に OpenSSL を利用していない。ドキュメントを読む限りでは crypt/tls.Config の RootCAs をカスタマイズしてやればよさそう。

func buildTransportWithIntermCertsParialChain() http.RoundTripper {
    rootCAs, err := x509.SystemCertPool()
    if err != nil {
        log.Fatal(err)
    }

    intermPEM, err := ioutil.ReadFile("/opt/intermediate-certs/ca-bundle.crt")
    if err != nil {
        log.Fatal(err)
    }

    rootCAs.AppendCertsFromPEM(intermPEM)

    return &http.Transport{
        TLSClientConfig: &tls.Config{
            RootCAs: rootCAs,
        },
    }
}

ただ、名前のとおりこれはルート証明書を格納するものなので、信頼できない中間証明書をここに入れることはできない。

いちおう中間証明書を与えつつチェーン全部を検証する実装らしきものは書けたけど、専門家の書いたものではないのでこれを信用してはいけない。まあ、参考程度に。

func buildTransportWithIntermCertsFullChain() http.RoundTripper {
    rootCAs, err := x509.SystemCertPool()
    if err != nil {
        log.Fatal(err)
    }

    intermPEM, err := ioutil.ReadFile("/opt/intermediate-certs/ca-bundle.crt")
    if err != nil {
        log.Fatal(err)
    }

    return &http.Transport{
        DialTLS: func(network, addr string) (net.Conn, error) {
            host, _, err := net.SplitHostPort(addr)
            if err != nil {
                return nil, err
            }

            conf := &tls.Config{
                InsecureSkipVerify: true,
                VerifyPeerCertificate: func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {

                    // https://github.com/golang/go/blob/go1.14.4/src/crypto/tls/handshake_client.go#L793
                    certs := make([]*x509.Certificate, len(rawCerts))
                    for i, asn1Data := range rawCerts {
                        cert, err := x509.ParseCertificate(asn1Data)
                        if err != nil {
                            return errors.New("tls: failed to parse certificate from server: " + err.Error())
                        }
                        certs[i] = cert
                    }

                    opts := x509.VerifyOptions{
                        Roots:         rootCAs,
                        CurrentTime:   time.Now(),
                        DNSName:       host,
                        Intermediates: x509.NewCertPool(),
                    }
                    for _, cert := range certs[1:] {
                        opts.Intermediates.AddCert(cert)
                    }

                    opts.Intermediates.AppendCertsFromPEM(intermPEM)
                    _, err = certs[0].Verify(opts)

                    return err
                },
            }

            return tls.Dial(network, addr, conf)
        },
    }
}

Node

https://github.com/motemen/sketch-interm-certs/blob/b896e1f/tests/node.test.js

Node も OpenSSL を利用している。ぜんぜん使ったことはないのだけど、https.Agent に設定を詰めるのがいいみたいだ。こちらは非常にかんたん。

  const agent = new https.Agent({
    ca: [
      fs.readFileSync("/etc/ssl/certs/ca-certificates.crt"),
      fs.readFileSync("/opt/intermediate-certs/ca-bundle.crt")
    ]
  });

ca オプションで証明書のコンテンツを指定する。ドキュメントから明らかじゃないように見えるんだけど、tls.connect の一部のオプションも Agent に指定できるみたい。ca には内容を指定するので、openssl コマンドや Perl で見たようにディレクトリを指定することはできない。

そして Perl の場合と違い、中間証明書を与えてもチェーン全体が検証されるようだ。これが意図通りなのかどうかまではわからない。内部で X509_STORE_add_cert使われているけど、Untrusted objects should not be added in this way.って書いてあるんだよな……。

逆に、自分の見たかぎりでは -partial_chain 相当の挙動を実現することもできなそうだった。

また、NODE_EXTRA_CA_CERTS環境変数を指定することでも ca にそのファイルの中身を追加したかのような挙動を実現できる。

まとめ

いろいろな言語による HTTPS (TLS) 証明書の扱いを見てみました。

冒頭に書いていた「雑に中間証明書を持っておいて、実行時にそれらの中間証明書まで含めて検証する、というアプローチが取れるか?」という疑問に関しては、わりと厳しそう。何にしろ、中間証明書は信頼できるものだけを手元においておいたほうがよいですね。

Dockerfile をベースイメージの更新に自動で追従させる

前回のエントリで作った Docker イメージ motemen/datastore-emulator は、google/cloud-sdk をベースにしているが、このベースイメージがけっこうな頻度で更新される。とうぜん自分はその追従に手を煩わせる気はなくて、全部自動でやってほしい。

やりたかったこと

  • google/cloud-sdk:x.y.z がリリースされたら、
  • リポジトリ中の ./Dockerfile と ./alpine/Dockerfile の FROM を google/cloud-sdk:x.y.z(-alpine) に更新し、
  • x.y.z タグを打って git push することで、
  • Docker Hub に x.y.z(-alpine) タグとしてリリースする

これを自動かつ無料で実現したい。

採用しなかった案: 自分でなんか作る

はじめは適当な GitHub Actions をこしらえて、Docker Hub のイメージ更新を検出する、ということをやるつもりだった。

更新フィードを Docker Hub が吐いてくれれば……と考えていたけど、イメージは日付が新しければよいというものではなく、*-slim みたいなバリエーションもある以上、タグをバージョンとして解釈して適切なものを選択する、としなくてはいけない。やればできそうなんだけど、Dockerflie の公式なパーザがないのはネックだった。それが理由じゃないんだけど、この案は不採用。もっと楽な方法があったからだ。

Renovate を使う

もうこれで説明しきったも同然なんだけど、最近試して気に入っていた Renovate が Dockerfile の更新に対応しているので、これを使う。簡単に言うと、依存関係を更新する Pull Request を作ってくれるやつです。

Whitesource Renovate - Automated Dependency Updates

Dockerfile を置いたリポジトリについて、Renovate を設定すれば勝手に更新を見つけてプルリクしてくれる。

docker:enableMajor という、Dockerfile でメジャーバージョンの更新を有効にする preset が効いていなかった不具合があったのだけど、つい最近解消されました

ちなみに Dependabot も Dockerfile の更新に対応しているが、複数の Dockerfile の更新をひとつの PR で処理する、というのができなさそうなので今回はそぐわなかった。他の理由もあって Renovate のほうが好みだというのもある。

自動マージを有効にするためにテスト書く

Renovate には automerge という設定項目があって、これを使うと(CI が通ったあとに)PR を自動マージするってことができる。CI をうまく構成すればハンズフリーで運用できそうだ。というわけで以下のようなテストを書く。

  • 全ての Dockerfile がベースイメージの同じバージョンに基づいていること
    • ./DockerfileFROM foo:1.2.3./alpine/DockerfileFROM foo:2.0.0-alpine だったらこまる
  • それぞれの Dockerfile が正しく派生イメージになっていること
    • ./alpine/Dockerfile*-alpine みたいなタグになってること、ということ

それぞれをテストするスクリプトを書いてGitHub Actions を仕込むFROM の解析は以下のような雑なワンライナーでお茶を濁している。

perl -nle 'print "$1\t$2\t$3" if /^FROM (\S+?):([0-9.]+)(?:-([^:-]+))?(?:$| )/' "$@"

うまく動いてマージされた Pull Request がこちらです。このエントリを書くために探すまで、バージョンが上がってたことに気づかなかったので成功ですね。

Update google/cloud-sdk Docker tag to v292 by renovate · Pull Request #6 · motemen/docker-datastore-emulator · GitHub

タグを打つ

最後に、master にマージされたら Dockerfile をベースに git tag を打ち、これをトリガに Docker Hub でビルドさせる。

これも先ほどの雑なワンライナーを使って、こんな感じにする。GitHub Actions の checkout@v2 であれば特に設定なしで git push できちゃうので、便利。

https://github.com/motemen/docker-datastore-emulator/blob/8aed69b/.github/workflows/tag.yml#L17-L23

tag=$(./scripts/parse-dockerfile-from Dockerfile | cut -f2)
echo "tag: $tag"
if ! git rev-parse --quiet --verify "refs/tags/$tag" > /dev/null; then
  git tag "$tag"
  git push --tags
fi

いかがでしたか? これが全部無料で実現できているだなんて、いったい自分は前世でどんな善行を積んできたんだ……とおののくばかりです。今生でもがんばりたいですね。

GitHub Actions から Cloud Datastore エミュレータを利用する

盆栽のテストを書くのに Cloud Datastore エミュレータ が必要になったので、GitHub Actions で利用してみた。

大きく分けて方法は2つある。

ジョブ中で明示的に gcloud beta emulators datastore start する

setup-gcloud というアクションで gcloud コマンドをインストールできる。これを使う方法。

このままでは必要なコンポーネントはついてこないので、加えて gcloud components install する必要がある。

また、エミュレータが起動したのを待つために、curl を --retry-connrefused つきで利用する、というわけで以下のようになる(全景はこちら)。

    - run: |
        gcloud components install cloud-datastore-emulator --quiet
        gcloud components install beta --quiet
    - run: |
        gcloud beta emulators datastore start --project prchecklist-test &
        curl http://localhost:8081 --silent --retry 30 --retry-connrefused --retry-delay 1
    - run: |
        eval $(gcloud beta emulators datastore env-init)
        ...

サービスコンテナを使う

上記の方法でもいいんだけど、本質的でないことに行数を費やしてしまっている気がしてしまってどうもよろしくない。

GitHub Actions にはサービスコンテナという概念があって、ジョブで利用したいサービスを宣言的に指定できるらしい。MySQL とか Redis とか使うならこれだろうから、Datastore もこれにしたい。

サービスコンテナは Docker イメージを起動するかたち。オフィシャルに google/cloud-sdk ってイメージがあって、これで gcloud コマンドを利用できるんだけど、見るかぎり GitHub Actions のサービスコンテナでは今のところ CMD を外から指定できないようなので、エミュレータを起動する CMD をもったイメージを作る必要がある。

雑に検索してみた感じだと、ベースのバージョンに追従しつつ信用できそうな(=リポジトリと連携して automated build が有効になってそうな)やつがないので自作する。ただ書いてみたかっただけかもしれない。

https://hub.docker.com/r/motemen/datastore-emulator

google/cloud-sdk に CMD と HEALTHCHECK を足しただけ。これを使うと、以下のように書けてスッキリした(全景)。

    services:
      datastore-emulator:
        image: motemen/datastore-emulator
        ports:
          - '8081:8081'
        env:
          CLOUDSDK_CORE_PROJECT: ...

そして

書いていたらまったく同じ課題を解決しようとしている記事をはっけんしました……(これを読んで自分の actions 設定を手直しした)。

budougumi0617.github.io

はてなで一緒に働きませんか?