Let’s Encryptでワイルドカード証明書を取得する話
2022年07月11日 月曜日
CONTENTS
はじめに
SoftwareDesign 8月号のDNS特集にて記事を書かせていただきました。みんな買ってね。
で、実は最初に書いてた原稿はもっと長かったんですけど、紙幅の都合で一部の内容については掲載を見送りました。せっかく書いたのに捨てるのはもったいないので、先日おこなわれたDNS Summer Day 2022で発表しようかと準備してたんですが、途中で気が変わって違う内容になりました。そんなわけで、最終的にエンジニアブログにて供養します。加筆修正しまくっているので元の原稿の気配はもはや残り香程度に漂うだけですが。
ACMEでdns-01チャレンジ
サーバ証明書を無料かつ自動で取得できるサービスとして有名なものにLet’s Encryptがありますが、Let’s Encryptの仕組みはLet’s Encrypt独自のものではありません。ACME (RFC8555)として標準化されていて、LE以外にもさまざまな認証局(CA)で採用されています。
TLSサーバ証明書の取得の際には、自分が正当なドメイン所有者であることを証明する必要があります。ACMEではその認証方法のうちひとつにdns-01チャレンジがあります。example.jp
というドメインに対する証明書を取得する場合、CAから指定されたトークンを_acme-challenge.example.jp
のTXTレコードに記述することで、そのドメインに対するコントロール可否を確認するというものです。
ドメイン認証をHTTPでおこなうhttp-01チャレンジでは、(サーバ構成にもよりますが多くの場合は)トークンをファイルとして作成するだけで十分です。一方、dns-01チャレンジでは、たとえば昔ながらの権威DNSサーバ構成ならば、プライマリサーバ上でゾーンファイルのTXTレコードとSOAレコードを書き換え、変更したゾーンを読み直すようサーバプロセスに伝えるコマンドを実行し、セカンダリサーバにゾーン転送されるのを待つという複雑な手順になり、手軽さに欠けます。
しかし、大手クラウド事業者が提供する権威DNSサービスであれば、たいていレコード更新のためのAPIが提供されており、またcertbotやlegoのような代表的なACMEクライアントも多くのクラウド事業者のAPIに対応しています。これを利用できるのであれば、むしろhttp-01チャレンジよりも簡単でしょう。
自前で権威DNSサーバを運用している場合は、dynamic updateが利用可能です。外部から動的にDNSのレコードを変更するもので、RFC2136で定義されています。BINDやKnot DNSなど多くの権威DNSサーバ実装で利用可能であり、ACMEクライアントの側でもサポートしているものが多く、こちらもそれほど手間がかからず利用可能です。
問題は、利用している権威DNSサービスでAPIが提供されていない、あるいはAPIはあるけどACMEクライアントがそれをサポートしていない場合です。また、自前運用でdynamic updateが利用可能な場合でも、実際に使ってみると人間がゾーンファイルに書いていたコメント文が失われたり、レコードの記述順が変更されてしまったりと、ゾーンファイルの可読性、メンテナンス性が低下してしまうため、できれば使いたくないと考える人も多いかもしれません。
しかし、dns-01チャレンジはhttp-01チャレンジと異なり、ワイルドカード証明書を取得できるという非常に大きなメリットがあり、ハードルは高くてもやってみるだけの価値はあります。そして、こういったケースでも、以下のような方法で簡単に自動化できるのです。
dns-01チャレンジを別サーバで
example.jp
というドメインの証明書をdns-01チャレンジで取得する場合、example.jp
自身ではなく、_acme-challenge.example.jp
という名前のTXTレコードにチャレンジトークンを記載します。ということは、_acme-challenge.example.jp
という名前「だけ」を、dynamic update可能な自前サーバなりAPIが利用可能なサービスなりに任せてしまえばいいのです。
これにはサブドメインを使う方法とCNAMEを使う方法の2通りの方法があります。
サブドメインを使う方法
example.jp
ゾーンで_acme-challenge
という名前のサブドメインを別サーバに委譲します。
_acme-challenge.example.jp. IN NS acme.example.jp. acme.example.jp. IN AAAA 2001:db8::bad:cafe
委譲されたサーバの方で_acme-challenge.example.jp
というゾーンを作成してdynmic updateできるよう設定しておき、このゾーンに対してdns-01のチャレンジトークンを更新します。
CNAMEを使う方法
こちらは_acme-challenge
をNSレコードではなくCNAMEにします。
_acme-challenge.example.jp. IN CNAME acme.example.com.
ただし、サブドメインの場合は委譲しても更新対象のレコードは_acme-challenge.example.jp
という名前のまま変わりませんが、CNAMEの場合はacme.example.com
という別の名前になりますので、ACMEクライアントの側がこれに対応している必要があります。対応クライアントとしては、acme.shやlego (experimental)などがあるようです(certbotは対応してないように見えます)。
一方、複数ドメインで証明書を取得する場合、サブドメインを使う方法はそれぞれのドメインごとにサブドメインを作成する必要がありますが、CNAMEを使う方法は複数のドメインでひとつのCNAMEを使いまわすこともできるというメリットがあります。
なお、RFC8555では_acme-challenge
を「ゾーンカットしてはならない」とか「CNAMEにしてはならない」などとは書いてないので、DNSの一般的な仕組みで名前解決できるなら問題なく、こういうやりかたしてもOKと解釈して間違いないでしょう。少なくとも、Let’s Encryptは使えることを明言しています。
とはいえ、せっかく自前での権威DNSの運用を手放して外部サービスを利用するようにしたのに、証明書取得用に自前サーバを動かすのならアウトソースした意味がないじゃないか、と思う人もいるかもしれません。しかし、_acme-challenge.example.jp
の名前解決ができる必要があるのは証明書取得作業中の短い時間だけです。つまり、DNSサーバとして常時稼動している必要はないのです。certbotは証明書の取得作業中だけ内蔵Webサーバを起動してhttp-01チャレンジで認証させることができますが、それと似たようなことをdns-01チャレンジでやればいいのです。
証明書を取得してみよう
ここまでがボツになった原稿(に倍ぐらい加筆したもの)。せっかくなのでこれだけで終わらせるのではなく、実際に具体的な設定例を上げて、ここまでで説明した常時稼動でないサーバにサブドメインを委譲する方法で証明書を取得してみましょう。
ステップ1: サブドメインを委譲する
まず、証明書を取得するドメインはexample.jp
とし、DNSSEC署名済みとします。DNS Summer Day 2022でも発表しましたが、証明書取得時の認証は平文でおこなわれ、悪意の第三者によるなりすましがあってもCAがそれを検知することは非常に困難です。CAがなりすましを見破れずに証明書を誤発行してしまうと、ブラウザはTLSがあっても偽サイトを検知できません。CAがこれを検知できるようにして誤発行を防ぐ、そして万が一誤発行があったとしてもブラウザが偽サイトに接続しないようにする対策のひとつがDNSSECです。「TLSがあるからDNSSECはいらない」ではありません。「TLSを有効に機能させるためにこそDNSSECが必要」なのです。できるだけDNSSECを使いましょう。
Let’s EncryptはIPv6に対応しているので、v4でアクセスできる必要はありません。IPv6アドレスはいっっっっっぱい余ってるので、証明書取得作業専用にひとつアドレスを割り当てることにしましょう。今回の例では2001:db8::bad:cafe
とします。もちろん既存のアドレスを使いまわしてもいいですし、IPv4アドレスでもかまいません。このIPアドレスを持った自前サーバにexample.jp
ゾーンからサブドメインを委譲します(この例ではCNAMEは使いません)。
_acme-challenge.example.jp. IN NS acme.example.jp. acme.example.jp. IN AAAA 2001:db8::bad:cafe
なお、サブドメインに委譲するためのNSレコードを登録できない権威DNSサービスがあるようですが、その場合はかわりにCNAMEを使ってください。また、そもそも”_
“を含む名前のレコードを登録できないサービスもあるようです。こちらはさすがにどうしようもないのであきらめてください。あんまり他社さんを批判するようなことは言いたくないですが、最近はACMEにかぎらずDMARCなどいろんな用途で”_
“ではじまる名前が使われますので、これを登録できないのは正直よろしくないです。別のサービスに乗り換えることも検討しましょう。別にIIJじゃなくてもいいので。
ステップ2: DNSサーバを設定する
委譲されたサーバではKnot DNSを使います(理由は後述)。パッケージを使うなり自力でコンパイルするなりdocker pull cznic/knot
してきたコンテナを使うなり方法はおまかせします。以下のような設定ファイルを書きます。簡素に見えますが、これだけの設定でdynamic updateで更新されたレコードをDNSSEC署名するというめんどくさいことを自動でやってくれます。
# この例ではIPv6 onlyだけど、もちろんv4でも可 server: listen: 2001:db8::bad:cafe # TSIG鍵 # "keymgr -t acme" の実行結果をコピペ key: - id: acme algorithm: hmac-sha256 secret: xxxxxxxxxxxxxxxxxxxxxxx # TSIG鍵によるdynamic updateを許可 acl: - id acme key: acme action: update template: - id: acme acl: acme # DNSSEC用(DNSSEC署名しないなら不要) module: mod-onlinesign zone: - domain: _acme-challenge.example.jp template: acme # 複数ドメインあるなら必要なだけ列挙する #- domain: _acme-challenge.example.com # template: acme
ゾーンファイルの中身はSOAとNSレコードだけ。
$ORIGIN _acme-challenge.example.jp. @ 86400 IN SOA localhost. nobody. 1 10800 3600 2419200 1200 86400 IN NS acme.example.jp.
サーバに証明書取得用のv6アドレスを付与して、まずこの設定でKnot DNSを起動してみます。実行例はFreeBSDです。Linuxな皆さんはifconfig
ではなくip
を使ってください。
# ifconfig lo0 inet6 2001:db8::bad:cafe/128 alias # knotd -d
まずはちゃんとゾーンが登録されてるかどうか確認します。Knotが設定にしたがって勝手にDNSSEC鍵を作成してくれて、勝手にDNSSEC署名してくれます(RRSIGレコード)。
# kdig @2001:db8::bad:cafe _acme-challenge.example.jp soa +dnssec ;; ->>HEADER<<- opcode: QUERY; status: NOERROR; id: 25697 ;; Flags: qr aa rd; QUERY: 1; ANSWER: 2; AUTHORITY: 0; ADDITIONAL: 1 ;; EDNS PSEUDOSECTION: ;; Version: 0; flags: do; UDP size: 1232 B; ext-rcode: NOERROR ;; QUESTION SECTION: ;; _acme-challenge.example.jp. IN SOA ;; ANSWER SECTION: _acme-challenge.example.jp. 86400 IN SOA localhost. nobody. 1 10800 3600 2419200 1299 _acme-challenge.example.jp. 86400 IN RRSIG SOA 13 3 86400 20220718080050 20220704063050 45831 _acme-challenge.example.jp. VUhUSYF0/jso4PmML4d9OAV72OccQUKDHF6XzSSBmD8tzcwgaTHv45ei3NGqdSppScEdZq5yFXDUAs6/B2eacg== (略)
dynamic updateのテストもしておきましょう。knsupdate
コマンド(BINDならnsupdate
)で追加したレコードを問い合わせると、DNSSEC署名つきで応答が返ってくることが確認できます。
# knsupdate knsupdate> server 2001:db8::bad:cafe knsupdate> key hmac-sha256:acme xxxxxxxxxxxxxxxxxxxxxxx knsupdate> zone _acme-challenge.example.jp. knsupdate> add _acme-challenge.example.jp. 3600 IN TXT "hogehoge" knsupdate> send knsupdate> answer Answer: ;; ->>HEADER<<- opcode: UPDATE; status: NOERROR; id: 41161 ;; Flags: qr; ZONE: 1; PREREQ: 0; UPDATE: 0; ADDITIONAL: 1 ;; ZONE SECTION: ;; _acme-challenge.example.jp. IN SOA ;; TSIG PSEUDOSECTION: acme. 0 ANY TSIG hmac-sha256. 1656926907 300 32 fooWXIP7zp34PVunHAcfHa2l76rjuqd8qnuWDVV9xqg= 41161 NOERROR 0 knsupdate> quit # kdig @2001:240::bad:cafe _acme-challenge.example.jp txt +noall +ans +dnssec _acme-challenge.example.jp. 3600 IN TXT "hogehoge" _acme-challenge.example.jp. 3600 IN RRSIG TXT 13 3 3600 20220718093828 20220704080828 7401 _acme-challenge.example.jp. 7SbB2klBA/y2vFWMGpfx3fo1p8vX5v0teEz/5hPArsdAwZEtIR4BPM84vTeZCjedDX88GLnz3sz7Oc0iBGO+pA==
DNSSEC署名をしないならここからの作業はしばらく飛ばしてください。DNSSECの信頼の連鎖をつなげるために、鍵ハッシュを親ゾーンに登録します。まずは登録すべき値を確認します。
# keymgr _acme-challenge.example.jp ds _acme-challenge.example.jp. DS 35442 13 2 1d640e4b43bfadcf6edb4198bb3fc5345387f5a217ff12426f985c36ad05229c _acme-challenge.example.jp. DS 35442 13 4 431db1d47bec5dbeb08080f3ab02029a13796d4fbf53b47cee293f84bdaeb4133e9b5475a173edd255de25ab74541cf6
これを親ゾーン(example.jp
)に登録します。2行出力されますが、DSレコードの3番目のパラメータが”2
“になってる方(実行例では上の行)だけで十分です。example.jp
ゾーンのすでにNSレコードとAAAAレコード存在するところに追加します。
_acme-challenge.example.jp. IN NS acme.example.jp. _acme-challenge.example.jp. IN DS 35442 13 2 1d640e4b43bfadcf6edb4198bb3fc5345387f5a217ff12426f985c36ad05229c acme.example.jp. IN AAAA 2001:db8::bad:cafe
DSレコードの登録が完了したことをKnotに教えてやります。
# knotc zone-ksk-submitted _acme-challenge.example.jp OK
DNSSEC関連の作業はここまでです。念のため、DNSVizでDNSSECまわりが問題なく設定できていることを確認するといいでしょう(※1)。たしかに手間は増えますが、めんどうというほど多くはなく、間違いやすいほど複雑な作業でもないことはおわかりいただけるかと思います。
ここまで完了したら、いったんKnotは停止します。IPアドレスも消しておきます。繰り返しますがFreeBSDでの実行例なので、Linuxはてきとーに読み替えてください。
# knotc stop # ifconfig lo0 inet6 2001:db8::bad:cafe -alias
ステップ3: ワイルドカード証明書を取得する
DNSサーバ側の準備ができたので、ACMEクライアントを使って実際に証明書を取得します。この例ではcertbotを使います。certbot本体とcertbot-dns-rfc2136プラグインをインストールしておいてください。また、Let’s Encryptで初めて証明書を取得する際はアカウントの作成やTerm of Useへの同意等が必要になります。これは今回の件とは直接関係ないので省略しますが適宜実施しておいてください。
dynamic updateに必要なパラメータを記載したファイルを作成しておきます。rfc2136.iniとします。
# DNSサーバのアドレス dns_rfc2136_server = 2001:db8::bad:cafe # knot.confのkey節の内容を転記 dns_rfc2136_name = acme dns_rfc2136_secret = xxxxxxxxxxxxxxxxxxxxxxx dns_rfc2136_algorithm = HMAC-SHA256
それでは、Knotが停止していることを確認したら証明書取得のコマンドを叩きましょう。
# certbot certonly -d example.jp -d '*.example.jp' --key-type ecdsa \ --dns-rfc2136 --dns-rfc2136-credentials rfc2136.ini --dns-rfc2136-propagation-seconds 5 \ --pre-hook "ifconfig lo0 2001:db8::bad:cafe/128 alias; sleep 1; knotd -d" \ --post-hook "knotc stop; ifconfig lo0 2001:db8::bad:cafe -alias"
引数を説明しましょう。example.jp
自身と、そのワイルドカードサブドメインでの証明書を取得するために-d
でそれぞれを指定しています。今回の件とは直接関係ないですが、RSAではなくECDSA証明書を発行してもらうため--key-type
を指定しています。
--dns-rfc2136
からはじまる引数がdynamic updateまわりのオプションです。このうち、--dns-rfc2136-propagation-seconds
は「浸透いうな」のプロパゲーションではなく、プライマリサーバからセカンダリへのNOTIFYやゾーン転送などのやりとりを考慮した待ち時間です。今回の例ではセカンダリサーバのない1台構成なので実際のところ5秒も待つ必要はありませんが、念のため長めにしています。
そして、--pre-hook
と--post-hook
でIPv6アドレスの付与/削除と、Knotの起動/停止を指定しています。これにより、ふだんは権威DNSサーバとしては稼動していない(それどころかIPアドレスすら存在しない)けれど、証明書取得にかかる数十秒の間だけDNSサーバとして起動するという動作になります。かわりに、dockerコンテナの起動/停止コマンドを入れておくなど、工夫のしがいがあるところでしょう。
その他、必要に応じて証明書取得後にWebサーバなどの設定リロードする処理を--deploy-hook
に仕込んでもいいかもしれません。
Knotの設定やcertbotの引数に問題がなければ、これでサーバ証明書が取得できているはずです。
# certbot certonly (引数略) Saving debug log to /var/log/letsencrypt/letsencrypt.log Plugins selected: Authenticator dns-rfc2136, Installer None Running pre-hook command: ifconfig lo0 inet6 2001:db8::bad:cafe/128 alias; sleep 1; knotd -d Requesting a certificate for example.jp Performing the following challenges: dns-01 challenge for example.jp Waiting 5 seconds for DNS changes to propagate Waiting for verification... Cleaning up challenges Running post-hook command: knotc stop; ifconfig lo0 inet6 2001:db8::bad:cafe -alias Output from post-hook command knotc: Stopped IMPORTANT NOTES: - Congratulations! Your certificate and chain have been saved at: /usr/local/etc/letsencrypt/live/example.jp/fullchain.pem Your key file has been saved at: /usr/local/etc/letsencrypt/live/example.jp/privkey.pem Your certificate will expire on 2022-09-28. 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"
certbot以外のACMEクライアントを使うケースでも、考え方は同じです。--pre-hook
や--post-hook
に相当するオプションが存在しないACMEクライアントもありますが、その場合はDNSサーバ起動→ACMEクライアント実行→DNSサーバ停止というシェルスクリプトを作っておきましょう。
ステップ4: 定期実行
あとは定期的にcertbot renew
を実行するようcron (Linuxならsystemd timer)を設定したらおしまいです。--pre-hook
や--post-hook
その他の引数はcertbot renew
でもそのまま引き継がれるので、あらためて指定する必要はありません。
補足1: mod-onlinesign
なぜKnot DNSを利用するかという点について説明します。今回はDNSサーバが常時起動しているのではなく、必要なときだけ起動してそれ以外の時間は眠っているというかなり特殊な形態で運用します。
しかし、サーバ証明書に有効期間があって定期更新が必要なのと同じように、DNSSECにも署名の有効期間があって定期的に更新する必要があります。署名鍵には有効期間はありませんが、セキュリティ的な理由でこちらも定期更新(ロールオーバー)する運用が一般的です。これらの処理を自動化する場合、通常はDNSサーバが常時動いている前提で再署名とゾーンのリロードを実施します。ところが、今回はDNSサーバがふだんは停止しているためこの方法ではうまくいきません。
そんなときに便利なのが、Knot DNSに含まれるmod-onlinesign
というモジュールです。DNSSEC署名というとふつうは各々のレコードに「事前に」静的な署名をしますが、mod-onlinesign
を使うと、「クエリを受けてから」動的に署名をすることができます。また、KSKとZSKという2種類の鍵を使いわける一般的な方式ではなく、CSK (combined signing key)というひとつの鍵だけでまかなう方式(※2)を使うので、いくつかのステップを数日かけておこなうZSKのロールオーバーのタイミングを考える必要がありません(※3)。
結果として、mod-onlinesign
を使うことによって一般的なDNSSECで考慮する必要があった「定期作業」の一切から解放されることになり、今回のような「たまにしか動かないサーバでのDNSSEC」という特殊な要件でもちゃんと動くようになります。なお、説明は割愛しますが、逆に通常のDNSSECにはない制限もありますので、「じゃあこれだけあればいいじゃん」とはならないことには注意してください。今回以外の用途では、正引き/逆引きレコードを動的生成するmod-synthrecord
との組み合わせなども相性がいい使い方になります。
なお、BIND(やmod-onlinesignを使わないKnot)でもCSKによるDNSSECはサポートされていて、また、dynamic updateで追加したレコードを自動署名することも可能です。今回のような使い方もたぶんできないことはないと思いますが試してはいません。
補足2: lame delegationじゃないの?
最初のステップとして、サブドメインに委譲するためのNSレコードを親ゾーンに登録しました。dynamic update用のサーバはほとんどの時間止まっていますが、このNSレコードは常時存在します。ということはサブドメイン側が動いていないときはlame delegationになってよろしくないのでは、と思うかもしれません。
はい、たしかに通常時はlame delegationです。しかし、そのサブドメインの名前解決が必要なとき(証明書取得時)にはlameではなくなりますので問題ありません。もしかすると証明書取得時以外に名前解決のクエリが来るかもしれませんが、そんなクエリに答える義理はないのでlameでも問題ありません。つまり、気にしなくていいです。
補足3: DNSテイクオーバーに注意
ここで説明した方法で証明書を取得する場合、NSレコードやCNAMEレコードで指定した別サーバの制御が第三者に渡らないよう十分注意してください。もし乗っ取られると、自分のドメインの証明書が第三者に取得される危険性があります。これについてはJPRSさんの解説(その1、その2)が詳しいのでぜひ読んでおいてください。
なお、DNSSEC署名していれば、NSレコードが万が一テイクオーバーされてもDNSSEC検証によって検知することができます。しかし、外部名のCNAMEを使っている場合(“_acme-challenge.example.jp. IN CNAME acme.example.com.
“のように、example.jp
でないドメインにCNAMEを向ける場合)はDNSSECがあっても防げません。example.jp
のDNSSEC署名はあくまでexample.jp
だけに有効で、それ以外のドメインには効力は及ばないためです。
さいごに
念のため、ここで説明したのは、あくまでexample.jp
ゾーンを直接APIで更新できない場合の次善策です。APIを使えるならわざわざサブドメインに委譲したりせず直接APIを叩きましょう。そして、IIJ DNSプラットフォームサービスはAPIを直接叩いてdns-01認証可能です。legoのIIJ DNSプラットフォームサービスへの対応についてという記事を読んでください。