ACME プロトコルで会話できるプライベート CA を立てる

はじめに

プライベート CA が ACME プロトコルを喋れれば、ACME クライアントを使った証明書の発行や更新を気軽に試せて便利です。

もちろん正規の証明書は得られませんが、その代わり、インタネットにポートを露出することなく HTTP-01 チャレンジや TLS-ALPN-01 チャレンジが行えます。公開認証局のリソース負荷やレート制限、CT ログなども気にする必要はなくなりますし、上位の DNS で応答をコントロールすれば、実在しない TLD の証明書も発行できます。

このエントリでは、気軽に実現できる方法のうち、実際に試した次の三パタンを紹介します。

  • Smallstep の step-ca を使う
  • Caddy の組み込みの acme_server を使う
  • Let’s Encrypt の Boulder または Pebble を使う

ACME クライアントを試す目的では、ふたつめの Caddy 案が個人的にはいちばん手軽で小回りが効く印象でした。

Smallstep の step-ca を使う

素直にドキュメントに従えば動いてくれます。コンテナ環境で動かしたい場合もチュートリアルがあるので安心です。なお、初期状態では ACME プロトコルでの証明書発行機能は無効化されているため、利用には明示的な宣言が必要です(三つめのリンクを参照)。

初期設定から起動まで

生の Docker ではなく Docker Compose で制御したかったので、最小限の Compose ファイルを作りました。設定はすべて /home/step の下にできるので、永続化はここだけ考えればよさそうです。

version: "3"

services:
  ca:
    image: smallstep/step-ca:0.15.6
    restart: always
    ports:
      - "9000:9000"
    volumes:
      - "ca-data:/home/step"

volumes:
  ca-data:

CA としての設定は CLI ベースで、環境変数などでの流し込みはできなそうでした。このため、必要な設定ファイル群がボリューム内に置かれた状態にするところまでは、手で操作していきます。

~/step-ca$ docker-compose run --rm ca sh
Creating network "step-ca_default" with the default driver
Creating volume "step-ca_ca-data" with default driver
~ $ step ca init
? What would you like to name your new PKI? (e.g. Smallstep): EXAMPLE.COM
? What DNS names or IP addresses would you like to add to your new CA? (e.g. ca.smallstep.com[,1.1.1.1,etc.]): ca.example.com
? What address will your new CA listen at? (e.g. :443): :9000
? What would you like to name the first provisioner for your new CA? (e.g. you@smallstep.com): jwk
? What do you want your password to be? [leave empty and we'll generate one]:
? Password: b)_.u<[R7fd(uT_zit1s1MNd9<H(JlVr

Generating root certificate...
all done!

Generating intermediate certificate...
all done!

? Root certificate: /home/step/certs/root_ca.crt
? Root private key: /home/step/secrets/root_ca_key
? Root fingerprint: ef17bfc5dffda4d23257330b9e5b6aa75727c9663b32f59666c679ce3ec489c7
? Intermediate certificate: /home/step/certs/intermediate_ca.crt
? Intermediate private key: /home/step/secrets/intermediate_ca_key
? Database folder: /home/step/db
? Default configuration: /home/step/config/defaults.json
? Certificate Authority configuration: /home/step/config/ca.json

Your PKI is ready to go. To generate certificates for individual services see 'step help ca'.

FEEDBACK
      The step utility is not instrumented for usage statistics. It does not
      phone home. But your feedback is extremely valuable. Any information you
      can provide regarding how you’re using `step` helps. Please send us a
      sentence or two, good or bad: feedback@smallstep.com or join
      https://github.com/smallstep/certificates/discussions.
~ $ echo 'b)_.u<[R7fd(uT_zit1s1MNd9<H(JlVr' > /home/step/secrets/password
~ $ step ca provisioner add acme --type ACME
Success! Your `step-ca` config has been updated. To pick up the new configuration SIGHUP (kill -1 <pid>) or restart the step-ca process.
~ $ exit

この段階で、必要なすべてのファイルができています。CA のルート証明書も自己署名証明書として作られ、中間証明書も生成されます。

あとは起動するだけです。

~/step-ca$ docker-compose up -d
Creating step-ca_ca_1 ... done

これで、以下の URL が ACME ディレクトリ URL として使えるようになります。

  • https://ca.example.com:9000/acme/acme/directory

ただし、このエンドポイントの HTTPS それ自体に、この CA のルート証明書(自己署名証明書)で署名された中間証明書が使われているので、このままではだいたいの(きちんとした)ACME クライアントではエンドポイントへのアクセスの時点で失敗します。

このため、CA のルート証明書をクライアント側で明示的に信用させるか、またはクライアントが(暗黙的に)参照している証明書ストアに登録する必要があります(システムレベルで登録することは推奨しません)。今回の場合、初期設定で生成された CA のルート証明書がコンテナ内の /home/step/certs/root_ca.crt に保存されているので、これを docker cp などで取り出して使います。

docker cp step-ca_ca_1:/home/step/certs/root_ca.crt .

ほかにも、コンテナ内には秘密鍵や中間証明書類も次のパスで生成されているので、必要に応じて利用できます。

  • /home/step/certs/root_ca.crt
  • /home/step/secrets/root_ca_key
  • /home/step/certs/intermediate_ca.crt
  • /home/step/secrets/intermediate_ca_key

このうち秘密鍵は、パスフレーズ(step ca init 中に入力または生成したパスワード)で保護されています。のっぴきならない事情があってパスフレーズを取り除きたい場合は、ssh-keygenopenssl コマンドで取り除けます。

  • ssh-keygen -p -m pem -f <鍵ファイル>
  • openssl ec -in <鍵ファイル> -out <鍵ファイル>

既存証明書の利用

CA で利用されるルート証明書として、初期化時に自動生成されるモノではなく、別途用意したモノを利用したい場合にも対応可能です。ドキュメントではいくつかの方法が紹介されています。

例えば、上記ドキュメントの最も簡単な手順(step ca init--root--key を渡す)に従う場合は、今回であれば次のようになります。ここでは、Compose ファイルは前述のモノのまま変更せず、一時的にルート証明書と秘密鍵を適当なパスにマウントして利用しています。

~/step-ca$ docker-compose run --rm -v $PWD/root_ca.crt:/tmp/root_ca.crt -v $PWD/root_ca_key:/tmp/root_ca_key ca sh
Creating network "step-ca_default" with the default driver
Creating volume "step-ca_ca-data" with default driver
~ $ step ca init --root=/tmp/root_ca.crt --key=/tmp/root_ca_key
? What would you like to name your new PKI? (e.g. Smallstep): EXAMPLE.COM
? What DNS names or IP addresses would you like to add to your new CA? (e.g. ca.smallstep.com[,1.1.1.1,etc.]): ca.example.com
? What address will your new CA listen at? (e.g. :443): :9000
? What would you like to name the first provisioner for your new CA? (e.g. you@smallstep.com): jwk
? What do you want your password to be? [leave empty and we'll generate one]:
? Password: 6~24(,i{um'3.RFd`2ol'k$,L2'2T>K8
...

秘密鍵にパスフレーズが設定されている場合は、途中で聴かれます。後続のパスワードファイルの作成や ACME プロトコルの追加方法は通常通りなので省略しています。この方法では、中間証明書は再生成されます。

中間証明書を含めて既存のものを使いたい場合は、先のドキュメントの 2 番めの手順に従って、特に工夫せずに step ca init してから、関連ファイルを置き換えるかマウントしてしまうのがラクそうです。

動作確認

例えば Caddy の Automatic HTTPS を使う場合であれば、Caddyfile の caca_root で、ACME ディレクトリ URL と CA のルート証明書を指定できます。

www.example.com {
    respond "Hello World!!"
    tls {
        ca https://ca.example.com:9000/acme/acme/directory
        ca_root /etc/caddy/root.crt
    }
}

Caddy では、DNS-01 チャレンジ用の設定がされていない場合、既定で HTTP-01 チャレンジか TLS-ALPN-01 チャレンジのどちらかがランダムで採用され、失敗時は他方にフォールバックします。いずれにせよ、上位の DNS でドメイン名の実 IP アドレスを適切にごまかしてやれば、任意のドメインの証明書が発行できます。

例えば、上位の DNS でエントリを以下のように登録しておきます。

  • Caddy から見た ca.example.com がプライベート CA を向くように
  • プライベート CA から見た www.example.com が Caddy を向くように

そのうえで上記の Caddyfile を利用すれば、実在しないドメイン www.example.com の証明書を取得できます。次のログは、TLS-ALPN-01 チャレンジが実行された様子です。

caddy_1  | {"level":"info","ts":1612702530.6507704,"logger":"tls.issuance.acme.acme_client","msg":"trying to solve challenge","identifier":"www.example.com","challenge_type":"tls-alpn-01","ca":"https://ca.example.com:9000/acme/acme/directory"}
caddy_1  | {"level":"info","ts":1612702530.6692169,"logger":"tls","msg":"served key authentication certificate","server_name":"www.example.com","challenge":"tls-alpn-01","remote":"192.168.160.1:53614","distributed":false}
caddy_1  | {"level":"info","ts":1612702530.9263132,"logger":"tls.issuance.acme.acme_client","msg":"validations succeeded; finalizing order","order":"https://ca.example.com:9000/acme/acme/order/xZCKSlnqGMB0V2YPWPUKNYEWiLvxHDHo"}
caddy_1  | {"level":"info","ts":1612702530.934317,"logger":"tls.issuance.acme.acme_client","msg":"successfully downloaded available certificate chains","count":1,"first_url":"https://ca.example.com:9000/acme/acme/certificate/ATdtrb5V2GuwyMuBKcd8j5XMIgAFZCoO"}
caddy_1  | {"level":"info","ts":1612702530.9358096,"logger":"tls.obtain","msg":"certificate obtained successfully","identifier":"www.example.com"}

Certbot でも、Certbot が動作する環境(コンテナ内など)の証明書ストアにルート CA の証明書を登録しておけば、証明書が発行できます。

# certbot certonly --standalone --server https://ca.example.com:9000/acme/acme/directory -d www.example.com -m admin@example.com --agree-tos
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Plugins selected: Authenticator standalone, Installer None
Obtaining a new certificate
Performing the following challenges:
http-01 challenge for www.example.com
Waiting for verification...
Cleaning up challenges

IMPORTANT NOTES:
 - Congratulations! Your certificate and chain have been saved at:
   /etc/letsencrypt/live/www.example.com/fullchain.pem
   Your key file has been saved at:
   /etc/letsencrypt/live/www.example.com/privkey.pem
   Your cert will expire on 2021-02-14. To obtain a new or tweaked
   version of this certificate in the future, simply run certbot
   again. To non-interactively renew *all* of your certificates, run
   "certbot renew"

Caddy の組み込みの acme_server を使う

Caddy は HTTP サーバやリバースプロキシとしての用途が一般的ですが、組み込みで ACME プロトコルで会話できる CA の機能を持っています。正確には、前述の Smallstep の step-ca そのものが内包されていて、Caddy で自己署名証明書を使う場合にデフォルトでこの CA 機能が利用されています。

この Caddy の組み込み CA 機能は、明示的に Caddyfile や API で指定すれば、外部からも利用できる状態になります。

初期設定から起動まで

Caddyfile に acme_server を追加すれば機能します。隅から隅まで自己署名証明書で完結させる場合は、例えばこれだけで充分です。

ca.example.com:9000 {
    tls internal
    acme_server
}

あとはこれを読むように Caddy を起動させれば、ACME プロトコルに対応した CA として利用できます。Compose ファイルではこのようになります。

version: "3"

services:
  caddy:
    image: caddy:2.3.0
    ports:
      - 9000:9000
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy-data:/data
      - caddy-config:/config

volumes:
  caddy-data:
  caddy-config:

この場合、ACME エンドポイントは次の URL です。

  • https://ca.example.com:9000/acme/local/directory

生成された証明書類はコンテナ内では次のパスにあります。

  • /data/caddy/pki/authorities/local/root.crt
  • /data/caddy/pki/authorities/local/root.key
  • /data/caddy/pki/authorities/local/intermediate.crt
  • /data/caddy/pki/authorities/local/intermediate.key

この方法では、ACME エンドポイントの HTTPS にもこの CA のルート証明書で署名された中間証明書が使われます。先の step-ca の例と同様、クライアント側でルート証明書を明示して信用させる工夫が必要になるでしょう。

なお、tls internal ではなくまっとうに Let’s Encrypt で証明書を発行するように構成(方法は割愛しますが Caddy の通常のお作法通りです)すれば、ACME エンドポイントの HTTPS には正規の証明書が使われるようになるため、クライアント側は何も気にせずに証明書を発行できるようになります(CA としてのルート証明書とは別物なので、当たり前ですが発行される証明書は自己署名ルート証明書に連なります)。

既存証明書の利用

CA としてのルート証明書に既存のものを使わせたい場合は、上記の 4 ファイルを上記通りのパスとファイル名でバインドマウントする方法がいちばん手軽そうです。この場合、Caddyfile は先の例と同じで問題ありません。

...
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - ./certs/ca.example.com/root_ca.crt:/data/caddy/pki/authorities/local/root.crt
      - ./certs/ca.example.com/root_ca_key:/data/caddy/pki/authorities/local/root.key
      - ./certs/ca.example.com/intermediate_ca.crt:/data/caddy/pki/authorities/local/intermediate.crt
      - ./certs/ca.example.com/intermediate_ca_key:/data/caddy/pki/authorities/local/intermediate.key
      - caddy-data:/data
      - caddy-config:/config
...

別のパスの証明書を使わせたい場合は、Caddyfile では表現できない部分なので、JSON フォーマットで流し込む必要があります。具体的には、pki アプリケーションとして次のように記述できます。

...
"pki": {
    "certificate_authorities": {
        "local": {
            "root": {
                "certificate": "/etc/caddy/certs/root.crt",
                "private_key": "/etc/caddy/certs/root.key"
            },
            "intermediate": {
                "certificate": "/etc/caddy/certs/intermediate.crt",
                "private_key": "/etc/caddy/certs/intermediate.key"
            }
        }
    }
}
...

この設定は、管理用 API を使って渡すほかに、起動時(caddy run 時)に --config <JSON ファイルのパス> を指定しても渡せます。

動作確認

動き出してしまえば、最初の step-ca の例とまったく同じ使い心地です。省略。

Let’s Encrypt の Boulder または Pebble を使う

Let’s Encrypt で実際に動いている CA の実装が Boulder で、それのテスト用の簡易版が Pebble です。どちらも GitHub で公開されています。

Boulder は実際に Let’s Encrypt で使われているだけあって、別途 RDBMS を必要とする重厚なモノです。一方 Pebble は、ACME クライアントの開発やテストで使うことを目的に提供されている簡易版です。自動テストへの組み込みも想定されており、テスト用のコマンドやライブラリ、フェイク DNS 機能などがいろいろ詰まった Challenge Test Server も併せて提供されています。

ここでは、Pebble をミニマム構成で動かします。

初期設定から起動まで

テスト用だけあって、少し変わった実装です。

例えば、ACME エンドポイントのポートは 14000 番がデフォルトで、HTTP-01 チャレンジの検証では発行先の 5002 番ポートを見に行きます。TLS-ALPN-01 チャレンジでは 5001 番です。

設定は JSON で変えられるので、ファイルとして用意し読ませることにします。

{
    "pebble": {
        "listenAddress": "0.0.0.0:14000",
        "managementListenAddress": "0.0.0.0:15000",
        "certificate": "/test/certs/localhost/cert.pem",
        "privateKey": "/test/certs/localhost/key.pem",
        "httpPort": 80,
        "tlsPort": 443
    }
}

Compose ファイルは次のような内容です。

version: '3'
services:
  pebble:
    image: letsencrypt/pebble:v2.3.1
    command: pebble -config /test/config/pebble-config.json
    ports:
      - 14000:14000
      - 15000:15000
    volumes:
      - ./config.json:/test/config/pebble-config.json

起動すると、次のエンドポイントで利用できます。

  • https://pebble:14000/dir

なお、このエンドポイントの HTTPS で使われる証明書(と鍵)は 公開 されており、これを署名している CA の証明書(と鍵)も 公開 されています。クライアント側ではこの証明書を信用する必要がありますが、鍵が公開されていて偽装が容易なため、テスト用の一時的な環境以外では信用させない方が安全です。

また、HTTPS の証明書が localhost127.0.0.1pebble 用のため、URL がこのいずれかでないとそれはそれでクライアント側の検証でハネられます。別ホストや別コンテナで動かす場合は、hosts ファイルや DNS で工夫が必要です。

HTTPS の証明書ではなく、CA としてのルート証明書と中間証明書は、起動するたびに再生成されます。管理用ポート(デフォルトは 15000 番)から HTTP でダウンロードできるようになっています。

  • https://localhost:15000/roots/0
  • https://localhost:15000/root-keys/0
  • https://localhost:15000/intermediates/0
  • https://localhost:15000/intermediate-keys/0 

既存証明書の利用

前述の通り、CA としての証明書や鍵は起動毎に代わる仕様です。根本的に Pebble はテスト目的の実装であり、そもそも永続的な利用を想定していないため、自前のルート証明書に置き換える手段は公式には用意されていなさそうです。

動作確認

動いてしまえば、使い方は最初の step-ca のそれと変わりません。

ただし、設定ファイルで httpPorttlsPort を変えていないと、HTTP-01 チャレンジや TLS-ALPN-01 チャレンジの検証でアクセスする先が 5002 番や 5001 番ポートになるので、その点は注意です。

# certbot certonly --standalone --server https://pebble:14000/dir -d www.example.com -m admin@example.com --agree-tos
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Plugins selected: Authenticator standalone, Installer None
Obtaining a new certificate
Performing the following challenges:
http-01 challenge for www.example.com
Waiting for verification...
Cleaning up challenges

IMPORTANT NOTES:
 - Congratulations! Your certificate and chain have been saved at:
   /etc/letsencrypt/live/www.example.com/fullchain.pem
   Your key file has been saved at:
   /etc/letsencrypt/live/www.example.com/privkey.pem
   Your cert will expire on 2026-02-13. To obtain a new or tweaked
   version of this certificate in the future, simply run certbot
   again. To non-interactively renew *all* of your certificates, run
   "certbot renew"

おわりに

ACME プロトコルで会話できるプライベート CA の立て方を 3 パタン紹介しました。個人的には、ACME クライアントを気軽に試す目的では、Caddy の組み込みの機能が設定要らずで簡単な印象です。

先の Caddy の Azure DNS 用モジュール の作成の際も、Let’s Encrypt にクエリを飛ばしすぎるのが(ステージング環境といえども)ためらわれたので、こうしたプライベート CA がほんのりと役立ちました。

お楽しみください。

@kurokobo

くろいです。ギターアンサンブルやら音響やらがフィールドの IT やさんなアルトギター弾き。たまこう 48 期ぎたさん、SFC '07 おんぞう、新日本ギターアンサンブル、Rubinetto。今は野良気味。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です