laravel などのWebアプリでPKI証明書発行を行いたい。
ドメイン証明書の発行は、ACME(v2)で自動化できるようになっている。
ACME は アクミ(日本ではアクメ)と呼びます。ACMEで自動化できる。
したがってACMEを組み込めば、自分のアプリが自分で証明書を発行して使えるようになる。
有名な ACME プログラム certbot
certbot がLet'sEncryptの証明書発行用に有名なプログラムで、これもACMEを活用している。
certbotはPythonで記述されている。しかも規模がデカくソースをちまちま読んでいくと大変である。
certbot を使って証明書発行は便利になるが、ACMEで直接証明書を発行するのに、仕組みを知るために読んでいくと大変である。
ACME の定義
読みやすい日本語訳が見つかったのでリンクを掲載しておきます。
RFC 8555 - Automatic Certificate Management Environment (ACME) 日本語訳
ACME はWEB API
ACMEには、いくつかの機能があり、機能は次のとおりである。
- registerAccount
- requestOrder
- reloadOrder
- finalizeOrder
- requestAuthorization
- reloadAuthorization
- challengeAuthorization
- requestCertificate
- revokeCertificate
中でも特に重要なのが、次の機能になる。
GET/POSTと書いているので分かる通り、ACMEはHTTP(s)で発行をリクエストする。つまりWEB APIである。
ディレクトリを取得する。
Web APIなので、エンドポイントがある。エンドポイントはディレクトリと呼ばれて、URLが公開されている。
## 本垢 curl https://acme-v02.api.letsencrypt.org/directory ## ステージング curl https://acme-staging-v02.api.letsencrypt.org/directory
実際に取得してみると、次のような一覧が出てくる。
curl https://acme-v02.api.letsencrypt.org/directory { "XuCiPPffcUk": "https://community.letsencrypt.org/t/adding-random-entries-to-the-directory/33417", "keyChange": "https://acme-v02.api.letsencrypt.org/acme/key-change", "meta": { "caaIdentities": [ "letsencrypt.org" ], "termsOfService": "https://letsencrypt.org/documents/LE-SA-v1.3-September-21-2022.pdf", "website": "https://letsencrypt.org" }, "newAccount": "https://acme-v02.api.letsencrypt.org/acme/new-acct", "newNonce": "https://acme-v02.api.letsencrypt.org/acme/new-nonce", "newOrder": "https://acme-v02.api.letsencrypt.org/acme/new-order", "renewalInfo": "https://acme-v02.api.letsencrypt.org/get/draft-ietf-acme-ari-00/renewalInfo/", "revokeCert": "https://acme-v02.api.letsencrypt.org/acme/revoke-cert
証明書取得の実験をするにはステージングのURLを叩けばい。
また pebble という 完全な実験用のACMEサーバーが用意されている。
JOSE+JSON
APIはJSONで呼び出すが、リプレイ攻撃に脆弱なので、JOSEを使う。
JOSEを簡単に説明すると、JSONにJSONのHMACをつける。共通鍵(Nounce)でハッシュを計算して送る。
JOSE(JavaScript Object Signing and Encryption)である。
JSOE/JSONでのリクエストは色々と使われるリクエスト手段ですから、HTTPクライアントは自作不要と思われます。よく、探せば見つかると思います。
ACMEの流れ
ACME証明書発行の流れ。
- ユーザを登録する( new user )
- ACMEに証明書注文を出す(new order )
- チャレンジに対応する。(ドメイン所有証明を配置)
- 検証依頼を出す ( challe )
- CSRを送信する
- 注文を完了し、証明書を受け取る。
ACMEサーバにHTTPリクエストを送信するときは、次のような手順を踏みます。
初回リクエスト
- ACMEディレクトリを取得する
- 最初の ACME API 呼び出しに対して nonce を要求
- HTTPヘッダを組み立てる
- HTTP JSONボディを組み立てJOSEを作る。
- 最後に、ACME API を呼び出します。
- ACME APIの後、HTTPレスポンスヘッダには2つの項目が返される
次回以降のリクエストでは、Nounceを使って送信し、ユーザを登録後は、ユーザキーで署名してリクエストを投げる。またnounce
はヘッダについてくるので注意しておく。
ドメイン所有の確認
ドメインの所有者であると証明するには、いくつかの方法がある。
この確認方法をチャレンジという。
通信プロトコルを使って所有者確認を取る。
HTTPとTLS-ALPNは、ドメイン名のAレコードで指定したIPアドレスにACMEサーバーがデータを取得する。
DNS は TXT レコードを使う場合。
- ACME サーバが指定したデータを取得
- 取得したデータを base64-url-safe な形式にする
_acme-challenge.example.tld TXT 指定データ
をDNS TXTに追記する。- ACME サーバがDNSのTXTを取得する
- TXT が一致すれば、DNSレコードを操作できるので、所有者だと証明される。
DNS-01 とワイルドカード証明書
Let'sEncryptは「ワイルドカード証明書」(DNS:*.example.tld
)を発行できる。
Let'sEncryptでは、ワイルドカード証明書の発行に、DNS-01が必須である。
そのため、HTTPやALPNのAレコードを使った認証ではワイルドカード証明書が発行できない。
世間にあふれる、HTTPサーバにファイル設置の方法やCertbotの基本的な使い方ではワイルドカード証明書の発行はできません。
DNSとHTTPの比較。
- HTTPではワイルドカード証明書は発行できません。
- HTTPではAレコードと当該IPの管理権を持っている証明であり、サブドメイン全体の所有証明にならない。
- DNSではTXTレコードを使ってDNSの書き換え権限を証明する。
- DNSでは、サブドメインのDNSの管理権の証明であり、サブドメイン全体のワイルドカードが出せる。
ということだと思います。
HTTP チャレンジの注意点
AAAA レコードとAレコードがある場合、AAAAレコードが優先されるので注意が必要。
IPv4 と IPv6 アドレスの両方 (例: A と AAAA レコード) を持つドメインに対して、外向きのドメイン検証のリクエストを行う場合、Let’s Encrypt は最初のコネクションでは常に IPv6 を優先して使用します。
ワイルドカード発行時チャレンジの注意点
ワイルドカードの証明書をマルチドメイン発行する際は、DNS-01のチャレンジが少し特殊です。
たとえば、次のようなワイルドカードでマルチドメインの証明書が欲しい場合
['*.luna.example.tld','luna.example.tld']
DNSチャレンジは、同じドメインに2つのTXTレコードが必要です。
ACMEでnewOrder すると次のような2つのチャレンジが要求されます。
{ "*.lua.example.tld":{ "domain" : "lua.example.tld", "status" : "pending", "type" : "dns-01", "url" : "https://acme-staging-v02.api.letsencrypt.org/acme/chall-v3/123/xxxxa", "token" : "ABCxxxxx.", "payload" : "ABCxxxxx.xxxxx" }, "lua.example.tld" :{ "domain" : "lua.example.tld", "status" : "pending", "type" : "dns-01", "url" : "https://acme-staging-v02.api.letsencrypt.org/acme/chall-v3/123/xxxxb", "token" : "XYZxxxx", "payload" : "XYZxxxx.xxxxx", } }
上記の2challengeは、どちらも対象ドメイン名がlua.example.tld
です。 サブドメイン'_acme-challenge.lua.example.tld' 'TXT' 'VALUE`を追加設定しACME検証要求を行います。ただし、それぞれのTXTレコードは異なります。この場合、2つのTXTレコードを同じドメイン上に作る必要があります。(または一つずつチャレンジと検証を2回行う。)。どちらも同じドメイン名なので、一つだけチャレンジすればいいと思いこんで、ハマりました。チャレンジが2つレスポンスされていることに気づかずにハマりました。
DNSと浸透待ち
DNS-01を使う場合、TXTレコードが更新されたか、どうやって確認するのか。
DNSレコードの格納先(たとえばroute53やcloudflare)のネームサーバに問い合わせれば良い。
つまり、DNSを管理しているNSに訊きに行くです。リゾルバではありません。大本に訊くのです。
ACMEサーバもリゾルバ(キャッシュサーバー)を使わず、ネームサーバ(コンテンツサーバー)に直接クエリ確認をしているようです。(仕様は確認してません。動作から推察しています。)
DNS-01で証明書発行をするとき、チャレンジ前にTXTレコード変更反映確認したいならNSに問い合わせが良いでしょう。DNSキャッシュサーバーではなくNSです。SOAレコードからNSを調べ、ドメイン名のNSにクエリを投げTXTレコードが変わったことを確認します。TXTレコード更新が確認できたらACMEに検証リクエストを送ればいいと思います。例えば以下のようになります。
DNSのNSを取得する。
次のようなSOAが応答が得られたら
$ dig t.co soa @1.1.1.1 +short ns1.p26.dynect.net. ops.twitter.com. 2540 3600 600 604800 60
NSを調べて
$ dig t.co ns @ns1.p26.dynect.net +short ns1.p26.dynect.net
TXTの更新をNSに問い合わせればよい。
dig _acme-challenge.t.co txt @ns1.p26.dynect.net
キャッシュサーバに訊くよりも、大元により近いところへクエリするべきです。浸透まちのような、「似非科学」みたいなことをやらずに。ちゃんと、NSに聞けばいいだけです。NSがTXTを返していれば、ACMEサーバは検証し証明書発行をしてくれます。
わたしが試したCloudflareの場合、albert.ns.cloudflare.com
に問い合わせすれば検証に成功しました。
- CloudflareのWEBサイト(もしくはCloudflareのAPI)でTXTレコードを入れる
- 数秒待つ
- Cloudflare 提供のNSがTXTレコードを返してくれる。
数秒待つ間は、浸透まちではなく、APIの結果がNSサーバに反映されるまでのタイムラグです。長くても20秒程度、最短だと5秒程度でした。確認したらTXTレコードが更新されていました。
この問い合わせには、外向きのDNS / UDP / 53番 が必要です。このポートが閉じている場合は、残念ながら、直接確認する術がありません。キャッシュリゾルバがキャッシュを破棄するまで待つしかありません。これは、ACMEサーバから見た問い合わせには関係のない話です。キャッシュリゾルバからみて未更新であっても、ACMEサーバーへ検証リクエストを送ればいいと思います。検証リクエストは何度も送信できるので、自分でTXTレコードの更新を確認できなくても、ACMEサーバ側から見えればいいのです。20秒程度待ってから検証を依頼すれば問題なく、DNS-01チャレンジ検証されると思います。
また、一部のキャッシュリゾルバ(systemd-resolved や dnsmasq )はSOAレコードやNS問い合わせにはに応答してくれないようです。(dig +traceを拒否する設定、unboundでのdisallow snoopのようなものがあるのかも?)このような理由から、DNSの更新をルータや8.8.8.8
に問い合せしても殆ど役に立ちません。というか、そもそもDNS更新を8.8.8.8に問合わせる行為自体に全く意味がありません。このような理由から更新の確認には、ドメイン名のNSサーバーに直接SOA/NS/TXTクエリを投げます。このため外向きののUDP/53に通信可能な状態が必要になるでしょう。
DNS更新待機時間を短くすると別の限界が訪れます。レート制限です。検証失敗後の再チャレンジには、レート制限があり、大量のドメインでの大量枚数の証明書発行は、DNS更新の待機時間よりもレート制限回避の待機時間のほうが長くなると思われます。
2つの鍵ペア
ACMEをするには、鍵ペアが2つ必要です。一つはユーザ登録に使う鍵ペア、もう一つはドメイン証明書に使う鍵ペアです。
ユーザ登録(鍵ペア、メールアドレス)
new-user でユーザ登録するには、メールアドレスと公開鍵ペアが必要になります。
Let'sEncryptで試したところメールアドレスは空テキストも問題なく証明書が発行されました。(ステージング限定かも)
Let'sEncryptでは同じ秘密鍵を使い続ける限り同じユーザーとして扱われるそうです。
署名リクエスト(CSR)と鍵ペア
証明書を発行するために、まず秘密鍵と公開鍵の鍵ペアをつくります。次に公開鍵に情報を入力し、署名リクエストを作成します。この署名リクエストをファイルとして保存し、ACMEサーバに署名を依頼します。
このときに使う、鍵ペアは、ユーザー登録に利用した鍵ペアとは別の鍵ペアを使います。ユーザ登録した鍵ペア流用して証明書発行を試みましたが、Let'sEncryptのACMEサーバに「ダメだよ」と拒否されました。
このことから、Let'sEncryptのACMEでは、ユーザー登録の鍵ペアと証明書の鍵ペア、2つの鍵ペアが必要でした。
署名リクエストと複数ドメインのSAN/CN
Let'sEncryptの証明書は、複数ドメインの証明書に対応していました。
Let'sEncryptの証明書リクエストでは、次のように複数ドメインを記入をします。
subject.commonName= 'example.tld', extensions.subjectAltName = "DNS:*.example.tld, DNS:example.tld"
subjectAltName(通称SAN)にDNS: example.tld
とDNSをつけてカンマ区切りで記入します。
commonName にベースドメインを入れて作るようです。
ただし、openssl でCSR(署名リクエスト)を作成する場合、subjectAltNameの格納にはちょっと手間が必要です。openssl.conf を明示しないとだめなようです。気づかずにハマりました。めんどくさかったです。subjectAltNameを入れたCSRの作り方は、ググれば山ほど出てきます。
commonNameを複数設定すれば良さそうな気もしますが、chrome59辺りでエラー判定されるようになったらしいです。
オーダー
ユーザ登録ができたら、オーダーをだします。
新規のオーダーをつくると、チャレンジ用URLの一覧と、オーダ・エンドポイントが出てきます。
チャレンジとオーダーはそれぞれ別のエンドポイントです。
チャレンジをリクエストして全部終われば、オーダーのエンドポイントを使って全部終了を確認します。
オーダーのステータスはpending
から、processing
へ、そしてvalid
へ変わります。(もしかしたらready も返されるかも)すべてが終わったら、オーダーエンドポイントに対して、CSRをPOSTします。証明書が発行されます。
オーダーはリロードすることもできるので、途中から再開もできるはずです。
このあたりは、チャレンジを何度も行うのことを前提に作られているみたいです。
発行される証明書
発行される証明書は、ANY_PURPOSE
がTRUEになっています。IPSecなどでも使えます。S/MIMEはFALSEだったのでクライアント(ユーザー)証明書としては使えないとおもいます。
有効期限は3ヶ月です。
証明書とペア秘密鍵とPKCS#12形式
証明書は、公開鍵とCAの署名です。公開鍵が正しいことを証明してくれます。公開鍵の元になる秘密鍵も合わせて保存します。certbot などでは、専用ディレクトリに証明書と鍵が別々のファイルとして保存されます。これら2つのファイル、証明書+秘密鍵を保存しておきます。この2つのファイルを別個に管理していると煩雑なので、一つのファイルに纏めて保存したいと思うでしょう。この目的に合致するのがPKCS#12形式です。PKCS#12 形式で保存しておけば、linux の openssl コマンドやライブラリで扱えるので便利です。保存時にパスワード設定が可能です。拡張子は'.p12'を使うようです。
DNS-01を使うメリットとデメリット
DNS-01で発行すると、ワイルドカードな証明書が作れるので、証明書の管理が格段に楽になる。
もし、ワイルドカード証明書がほしいと思ったら、DNSのTXTレコードを使うしかない。
デメリット。手順に関してデメリットは無いです。ただ情報が少ないです。極端に。
世の中の大半のLet'sEncryptとCertbotの記事は、HTTP-01を使った例が多い。ググった結果がHTTPばかりで役に立たずゴミです。仕方ないです。マニュアルを読みましょう。certbot をキーワードに入れた瞬間にdns チャレンジの細かい話は、記事数の問題で埋もれてしまいます。
renew と force-renewal の違い
certbot には renew と force-renewal の使い分けがある。ACMEを見る限り、サーバ側でrenew を検出しているわけではなさそう。ACMEサーバは単純にCSRに基づく証明書を発行する機能があるだけ。renew はコマンド実行時に有効期限をみて、有効期限が30日以上であれば処理をスキップしている。ということでしょう。force-renewal は、CSRを再生成し証明書を再発行している。force は30 日以上のスキップを省略するということだと思う。ただ、鍵ペアを再生成しているかどうかは、コードを見ているので分からない。
更新(renewl)と判定される基準
Let'sEncryptで、更新と判定されるのはどのようなときか。これは公式サイトに記述がある。
発行済の証明書が更新 (または複製) の対象とみなされるのは、全く同じホスト名の集合が指定されているときです (大文字・小文字、順序は区別しない)。たとえば、[www.example.com, example.com] というドメイン名に対する証明書をリクエストした場合、同じ週に [www.example.com, example.com] に対する証明書を重複して発行できるのは、追加で 4 つまでです。しかし、[blog.example.com] というドメインを追加してホスト名の集合が変化したときには、追加でリクエストを発行できます。
更新の処理では、公開鍵とリクエストの拡張は無視されます。新しい鍵を使用していたとしても、証明書の発行は更新とみなされます。
つまり、鍵ペアを新しくしても新規とはみなされない。ドメイン名の一覧が同じであれば更新として扱われる。
公式サイトには次のような記述がある。
同じ週に [www.example.com, example.com] に対する証明書を重複して発行できるのは、追加で 4 つまでです。
このことから、「同じドメイン」に対して、鍵を変えた証明書を複数枚重複して発行する事が可能であり、鍵を変えても別の証明書として看做されない。同じドメインの証明書は複数枚存在できる。
証明書を紛失したからと鍵を変えて発行したとき、追加・再発行扱いになり。Revokeしてないからといって再発行を拒否されることもない。
revoke の取り扱い。
revoke は再生成とは違う。鍵が危殆化したときにつかう。証明書とは公開鍵の証明書なので、秘密鍵が漏れたとき、証明書は無効にしないといけない。もし、同じドメインで2枚の証明書を贅沢に発行したとき、有効期限内であれば両方とも使えるはずである。同じ秘密鍵を使ってないとしても2枚の証明書がある。秘密鍵が漏洩したとき、新しく鍵ペアから証明書をつくったとしても、漏れてしまった秘密鍵の証明書は有効期限内であれは通用してしまうので、Revokeする。
上記の「更新」の判定で書いてあるが、鍵ではなく「ドメイン名の集合」で見ている。同じドメインの証明書は複数枚発行される。そのため秘密鍵をお漏らししたとき、新規で証明書を取り直す。鍵を変えたあとでも証明書は有効期限内に通用可能であるので、Revokeしないといけない。
DNS更新をしたい。
ここまで考えて、Let'sEncryptはやはりDNS更新でワイルドカードで使うのが一番便利だと思われる。
証明書発行するのに、もっと手軽にボタンひとつで出来ないか。スマホでWebサイトの管理画面を開いてポチッと押すだけで、ドメイン作成から証明書発行と更新を簡単にできないか。
ACMEを各種のwebアプリケーションに組み込めないかと考えた結果。AcmePHPを使えば既存PHPのWEBアプリケーションに証明書作成機能組み込めるじゃないかと。
流石にJSOE/JSONリクエストから作る気力はないので、DNS更新の部分だけでも省力化したい。certbotとsystemd や cron の呪縛逃れてdocker軽量アプリで証明書を管理したいなと思った次第です。