HTTPS サイトでもはや旧来型の証明書を使わない理由
(rachelbythebay.com)- 筆者は ACME プロトコルの 複雑さと実装リスク のため、長年敬遠していた
- 既存の ACME クライアントには セキュリティ上危険、または難解なコード が多く、自分で実行するのをためらっていた
- しかしドメイン登録業者 Gandi の品質低下と価格上昇により、証明書更新ツールを自作 することになった
- 数多くの試行錯誤の末、Let's Encrypt を通じて 自分で証明書を発行するツール を完成させることに成功
- 記事後半では ACME プロトコルの実際の動作過程と、JSON、base64、署名などの低レベル実装ディテール を詳しく説明している
Why I no longer have an old-school cert on my https site
背景ときっかけ
- 2023年初めには 旧来型の証明書を使い続ける理由 を説明していたが、2025年の現在は その方式をやめることにした理由 を共有している
- ACME プロトコルへの拒否感は 2018 年からあり、複雑な Web 技術と難解なエンコーディング方式 が大きな障壁だった
- ほとんどの ACME クライアントは信頼しにくいコードで、root 権限で動かすには危険 だと判断していた
- Gandi がプライベートエクイティに買収された後、品質が落ち価格も上がり、もはや従来の証明書を維持する理由がなくなった
自前実装の始まり
- 既存ツールは使わず、まずは 小さなユーティリティ関数をひとつずつ 実装していった
janssonという C 向け JSON ライブラリを C++ から使えるようにラップする作業から始めた- JWK(Key 構造体)生成のために複数のライブラリを検討したが、ほとんど役に立たず、自分で実装することを決めた
- 途中で何度も止まり、また再開する過程を繰り返しながら、少しずつ 小さな構成要素をつなぎ合わせていった
テスト環境と実運用への適用
-
Let's Encrypt の実サーバーを直接触らないために、
pebbleというテスト用 ACME サーバー を独立環境で使った -
数えきれない失敗の末、CSR を入力として証明書を発行する 初期版ツールを完成 させ、
- Let's Encrypt staging サーバーでテスト成功
- 本番環境でも成功
- 実際の Web サイトにも適用完了
ACME プロトコル詳細解説
- RSA 鍵を生成し、CN と SAN を含む CSR(Certificate Signing Request)を作成
- ACME ディレクトリ URL から JSON をパースして
newNonce、newAccount、newOrderなどのエンドポイントを抽出 - 秘密鍵から modulus と public exponent を抽出 し、これを Web 向けの base64url エンコード に変換
- JWK を生成した後、JSON payload とともに RSA SHA256 で署名
- HTTP HEAD リクエストで Nonce を受け取ってから、署名済みリクエストを POST してアカウントを作成
- レスポンスの
Locationヘッダーは実際のリダイレクトではなく、アカウント識別子 URL として使われる
ACME プロトコルの複雑さ
- 単なる証明書発行であるにもかかわらず、
- SHA256 ハッシュ、base64web、JSON の中に JSON がある構造、RSA 署名
- HEAD リクエスト、Location ヘッダーによるアカウント識別、使い捨て Nonce の必要性など
- まだ証明書注文、ドメイン所有権の証明(TXT レコードなど)、認証完了といった部分には 手を付けられていない段階 だと述べている
- 一部クライアントでは publicExponent のエンコード実装が間違っていても動作する例があり、標準の緩さ も指摘している
結論
- ACME はあまりにも複雑で、自前実装には膨大な試行錯誤と労力が必要なシステム である
- それでも旧来型の証明書をやめ、完全自動化方式への移行に成功した ことを共有している
- この複雑さはひょっとすると 誰かの仕事を守るための仕組みではないか という冗談も添えている
1件のコメント
Hacker News のコメント
私は Let’s Encrypt の SRE/infra チームのテクニカルリードで、こうした問題についてかなり考えている立場です
JSON Web Signature は本当に厄介なフォーマットで、ACME API も RESTful であることにかなり強くこだわっています
自分で設計していたら、こんな作りにはしなかったと思います
こうした構造になった背景には、IETF が IETF 標準を多用しようとした意図と、委員会方式のデザインが一役買っていたのだと思います
JSON、JWS、HTTP 用のライブラリがいくつかあるだけでもかなり楽になりますが、特に C ではそれらのライブラリですら使いやすくないのが問題です
RFC の言語自体も複雑で、他の文書を多数参照することも多いため、それを補うためのインタラクティブなクライアントやドキュメントを別途作っています
JSON Web Signature が厄介なフォーマットだというのがあまりピンときません
私は ASN.1、Kerberos、PKI など複雑なものを多く扱う立場ですが、JWS がそこまで難しいフォーマットだとは思いません
実際に自分でコードを書くとしても、S/MIME、CMS、Kerberos などよりはるかに簡単だと思います
JWS のどこが「厄介」なのか、もう少し説明が必要です
JWT の問題であれば、HTTP ユーザーエージェントが標準的に JWT をどう受け取り、どう要求するべきかがあまり定まっていない点のほうが本質だと思います
「証明書を 3 つ以上発行してもらうにはお金がかかる」という話を見かけた人がいましたが、私はこの 5 年間使っていて請求されたことは一度もなく、誤解か誤情報だと思います
「e=65537」ではなく「e=AQAB」として扱う部分について、原因は JSON が数値をまともに扱えない性質にあると説明しています
もし 4723476276172647362476274672164762476438 のような非常に大きな数を JSON パーサーに渡すと、多くの JSON パーサーは黙って 64 ビット整数や float に切り詰めるか、運がよければエラーを出します
Common Lisp のような言語なら問題なく扱えるでしょうが、実際にはそういう環境で開発している人は多くありません
そのため、JSON で巨大な数値を安定して渡すには、いっそ base64 でバイト配列に変換するほうがましだという考えです
一見何事もなく通っているように見えても、これがさまざまなセキュリティ問題の原因になるので、プロトコル内のすべての数値をこう扱うのも妥当だと思います
ただしこの方式には JSON の人間向けの可読性が失われる欠点があり、個人的には標準化された S-Expression のほうがずっとよい選択だと思います
しかし世界は JSON を選びました
なぜ世界が JSON を選んだのか理解できないなら、それは意図的に無視しているのだと思います
JSON は大半のデータについて、人が簡単に手で書き、編集し、読むことができます
一方で Canonical S-Expression は各要素の前に長さ情報を付けなければならず、手作業では非常に面倒です
S-Expression を書くには毎回文字数を数えて接頭辞も直さなければならず、とても煩雑です
予想に反して、こうした手作業のしやすさと編集のしやすさこそが JSON が生き残った理由だと思います
参考までに、Ruby の JSON パーサーは大きな数値もきちんと扱えます
C# アプリで JSON serializer が BigInt を数値として出力し、それを JS 側で受け取って黙って誤解釈するバグに悩まされたことがあります
エラーではなくオーバーフローが標準動作だというのはいまだに驚きです
それ以来、32 ビットより大きい数値は必ず文字列として扱うようにしています
{"e":"AQAB"} と {"e":65537} の比較には一理ありますが、{"e":"65537"} と比べれば、これもすべての JSON パーサーで処理結果は同じです
数値であれ文字列であれ、明確に変換されます
もちろん結果が double に入らないほど大きな数なら、それは言語やパーサーの問題ではありますが、表現方法とは別の話だと思います
JSON の問題はフォーマット自体ではなく、パーサーがもともと JS の型マッピングのために作られていることにあると思います
一部のパーサーはうまく扱えても、そうすると JSON の移植性が失われます
Base64 に変換しても同じ問題が起こります(標準と異なるためです)
replacer や reviver でカスタムパースは可能ですが、すべての環境でこの機能が保証されているわけではありません
結局、標準パーサーで JSON を解釈するという前提そのものがエラーの根源です
JSON ではなく別のフォーマットだと呼べば問題は減るかもしれませんが、人は見た目が JSON なら結局そのままパーサーに投げ込もうとするでしょう
Go 言語では json.Number 型によって、数値を損失なく文字列としてデコードできます
ほぼ私の「最推し」の任意精度 decimal 型の 1 つを紹介します https://github.com/ncruces/decimal?tab=readme-ov-file#decimal-arithmetic
半分冗談ですが、この件で S-Expression がより優れている理由はあまり分かりません
Lisp の中にも任意精度演算をサポートしていないものはあります
ACME と複数のクライアントに対して筆者が批判的なのが不思議でした
単に使いこなしの問題という感じでもなく、ACME という概念そのものや周辺ツール全般に反感があるように見えました
私たちも 2019 年から LE ベースでいくつかのサイトに導入してきて、その間に複数の ACME クライアントを使ってきました
たとえば Crypt-LE は私たちの用途には十分で、Sectigo ACME と連携しようとした際には le64 では足りず、certbot、lego、posh-acme なども試しました
最終的には certbot で GHA 環境の問題を直して使い、posh-acme もよかったです
読み返してみると、筆者の鋭いトーンは ACME やクライアントではなく、仕様そのものに向いていました
ACME というアイデア自体はよいが、実装と現実での適用には失望した、という結論です
筆者とかなり近い見方だと思います
「既存クライアントの多くは危険なコードで、私のサーバーで root 権限を持って実行させるには信頼できない」という筆者の言葉を引用します
セキュリティに敏感な作業においては、こうした慎重な態度は妥当だと思います
元記事の口調が分かりづらかった人向けに、文脈を補う昔の投稿へのリンクを紹介します
サーバー上で理解できないものを動かすこと自体を嫌う人は多く、私もその感覚には共感します
ですがセキュリティの世界は猫とネズミのゲームであり、変化し続けるのが本質なので、結局は追従するしかありません
幸い ACME には、自分で好きなクライアントを作れる自由があります
certbot を必ず使う必要もなく、TPM のように自分のリソースをロックする仕組みでもありません
ACME クライアントを最初から実装しようとするなら、RFC 群(および関連する JOSE などの文書)を直接読むのは思ったより簡単だった、という経験談があります
実際に自作もしており、ACME v2 の流れを理解するための解説記事も書いて共有しています https://www.arnavion.dev/blog/2019-06-01-how-does-acme-v2-work/
公式 RFC の代わりにはなりませんが、この解説をフローチャート兼方式別インデックスのように使うとよいです
MIT のセキュリティ授業の最終課題として ACME クライアントを自作したこともあります https://css.csail.mit.edu/6.858/2023/labs/lab5.html
いちいちマニュアルを読むより、英語で全工程を説明する文章を Hacker News に投稿したほうが、より多くのインターネットポイントを稼げるという妙な現実を皮肉っています
Web インフラのプロトコルがますます複雑化していると指摘した筆者に感謝する声があります
こうした標準は、単にツールやクライアントを使うだけの開発者にとっても負担ですが、それ以上に「規制の壁」のように機能して、最終的には既存の大企業だけがインターネット運営の要件を満たせる構造を作っているように見えます
ACME ひとつだけでは越えられない障壁というほどではありませんが、積み重なって最終的にひとつの壁になります
OpenBSD には、ベース OS に含まれている非常にシンプルで軽量な ACME クライアントがあります
既存の代替手段があまりに重く、Unix 哲学にも反しているため新しく作られたと聞いています
筆者がこちらを検討していないようなのは残念です
おそらく他の OS にも少し手を入れれば移植できるでしょう
この OpenBSD クライアントは、むしろ OpenBSD の哲学が、なぜセキュリティがここまで複雑なのかを理解していない例だと思います
このクライアントは対象マシンにインストールして使い、分離構造によって各要素が相互に影響しないように作られています
ですが ACME プロトコル自体は完全な分離構成(air-gapping)が可能で、Web サーバー、証明書リクエスタ、DNS サーバーが別々の環境にあっても問題ありません
OpenBSD の統合クライアントを使わない場合のほうが、より複雑かもしれませんが、セキュリティ設計原則としては優れていると思います
「OpenBSD を入れれば終わり」というのは、単に手軽な方法にすぎません
uacme (https://github.com/ndilieto/uacme) も紹介されています
軽量な C コードで、LE の Python クライアントでバッテリーの問題に悩まされた末、代替として安定して使っているとのことです
OpenBSD の ACME クライアントを実際に使っていますが、とてもうまく動いています
「4096 ビット RSA 秘密鍵を作れ」という推奨は、実際には訪問者の速度低下を招くだけで、実効的なセキュリティ向上は 2048 ビット相当しかありません
2048 ビットのリーフ証明書を使うほうがよいと強調しています
4096 ビットなら、パッシブキャプチャからの将来復号に対してより強くならないのか、という疑問があります
中間証明書の安全性も非同期的な攻撃に影響するのか気になる、という話です
Web ホストが RSA 鍵しかサポートしていないので、わざと 4096 ビット RSA を使って、早く EC 鍵対応を入れてくれと促しています
こういう作業を自分でやれば腕は上がりますが、筆者の文体はプロトコルや Let’s Encrypt の導入プロセスそのものに苛立っているようにも見えます
lightweight ACME ライブラリ(https://github.com/jmccl/acme-lw など)でも十分自動化できるのに、なぜここまで苦労しているのか不思議です
フラグやビットフィールドの問題はすべて ASN.1/X.509 の歴史的遺産に由来しており、数学的な複雑さも深刻で、あらゆるライブラリとソフトウェアが 80 年代の技術的制約に縛られています
Let’s Encrypt 導入時や HTTP/2 登場時にはこの混沌を整理する最後の好機がありましたが、現実には ACME CA はシェルスクリプト・OpenSSL・酒だけで組めてしまうし、既存ソフトウェアとの互換性もあるため、大きく跳躍することはできませんでした
HTTPS への移行圧力がますます強まっているという体験談があります
たとえば WhatsApp では HTTP リンクがもう開けなくなっています
プロキシやキャッシュを使えばトラフィック負荷を減らせるので、小規模サーバーにはよい方法だという提案があります
ACME がどれだけ複雑でも、TLS 非対応よりははるかにましだという点は強調しておきたいです
「RSA 鍵、SHA256 ダイジェスト、RSA 署名、実際には base64 ではない base64、文字列連結、JSON の中の JSON、301 リダイレクトの代わりに識別子として使われる Location ヘッダー、単一ヘッダー値のための HEAD リクエスト、さらにすべてのリクエストのたびに nonce 用の別リクエストが必要など、要素が折り重なっている」
「そのうえ、証明書オーダーの作成、認可・チャレンジ処理、キーのサムプリント、TXT レコード構成など、さらに複雑な工程が残っている」
信じがたいほど複雑で、整理して共有してくれてありがとう、という応援のメッセージです