Let’s Encryptでワイルドカード証明書を取得する話

2022年07月11日 月曜日


【この記事を書いた人】
やまぐち

アプリケーションサービス部所属。そのへんのおっさん。

「Let’s Encryptでワイルドカード証明書を取得する話」のイメージ

はじめに

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.shlego (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プラットフォームサービスへの対応についてという記事を読んでください。


  1. この例のとおりにやるとIPv6のアドレスしかないので、dnsvizは「v4でアクセスできないぞー」という警告を出すが、もともと意図してそうしてるので問題ない。[↑]
  2. CSKは形式的にはKSKと同じでDNSKEYレコードのフラグは257として見えるが、KSKがDNSKEY以外のレコードを署名しないのに対して、CSKはZSKと同様にゾーン全体のレコードを署名する。なので、実体としては、SEPフラグ(secure entry point)の立ったZSKと考えるのがいいかもしれない。[↑]
  3. KnotはデフォルトではKSK、CSKはロールオーバーしないので、こっちはもともと考えなくてよい。[↑]

やまぐち

2022年07月11日 月曜日

アプリケーションサービス部所属。そのへんのおっさん。

Related
関連記事