世界を動かす技術を、日本語で。

なぜ私のHTTPSサイトには古いスタイルの証明書がなくなったのか

概要

  • ACMEプロトコル導入の経緯と理由を説明
  • 既存クライアントやライブラリへの不信感と独自実装の動機を記述
  • 実装過程で遭遇した技術的課題と解決策を整理
  • 主要な手順や必要な処理を箇条書きで解説
  • ACMEの複雑さや感想についても触れる

ACMEプロトコル導入の経緯と背景

  • 2023年初頭、「old-school cert」をhttpsサイトで使い続けていた理由を説明する投稿を執筆したことを振り返ること
  • ACMEプロトコルの存在は2018年から認識していたが、仕様や既存クライアントの安全性・設計に強い不信感を持ち続けていたことを強調
  • 既存クライアントはプライベートキーやroot権限へのアクセス権限を与えるには危険すぎると判断し、導入を拒否していた経緯を説明すること
  • Gandiがプライベートエクイティに買収されサービス品質や価格が低下し、2025年以降の利用継続に疑問を持ったことが転機となったことを記載
  • 既存サービスから離脱し、ACME導入を決断するに至った提案

独自実装への決意と初期ステップ

  • 既存のACMEクライアントやライブラリの「コードの質」に疑念を持ち、自分自身で小さなユーティリティやライブラリをC++で実装し始めたことを説明すること
  • JSON処理のためにjansson(Cライブラリ)をラップするなど、必要最小限の機能から段階的に構築すること
  • JWKなどの生成ライブラリも検証したが、期待通りに機能せず何度も行き詰まりを経験した確認
  • 問題を細分化し、少しずつ進展するアプローチを採用すること
  • テスト用ACMEサーバ「pebble」を利用し、実際のCAを煩わせずに開発・検証を進めたことを強調すること

実装手順と主要処理

  • RSA鍵(4096ビット)を作成し、ウェブサイト用のCSR(証明書署名要求)を作成すること
  • CSRからCN(Common Name)とSAN(Subject Alternative Names)を抽出し、CNがSANに含まれることを検証すること
  • ACMEサービスオペレーターから提供される<directory URL>にHTTP GETし、"newNonce"、"newAccount"、"newOrder"などの値をJSONから抽出すること
  • RSA鍵ファイルからpublicExponent(通常65537)とmodulusを抽出し、バイト配列として保持すること
  • publicExponentをビッグエンディアンのバイト列(例: 0x01, 0x00, 0x01)に変換し、base64web("+"→"-"、"/"→"_")エンコードすること
  • JWK(JSON Web Key)オブジェクトを作成し、"e"(publicExponent)、"kty"("RSA")、"n"(modulus)を設定すること
  • "termsOfServiceAgreed": true を含むJSONオブジェクト(payload)を作成すること
  • "newNonce"のURLにHTTP HEADリクエストし、ヘッダーから"Replay-Nonce"の値を取得すること
  • "newAccount"のURLを使い、"protected"(JWKやnonce等を含むJSON)、"payload"(上記JSON)、それらを連結した文字列をSHA256でダイジェストし、RSA署名・base64webエンコードした"signature"を作成すること
  • これらをまとめてJOSE(JSON Object Signing and Encryption)形式でPOSTし、レスポンスヘッダーの"Location"からアカウント識別用URLを取得すること
  • ここまででACMEアカウントの作成が完了することを確認すること

ACMEプロトコルの複雑さと感想

  • RSA鍵、SHA256ダイジェスト、RSA署名、base64webエンコード、JSONのネスト、LocationヘッダーをIDとして利用、nonce取得のためのHEADリクエストなど、多数の複雑な処理が必要であることを強調すること
  • オーダー作成、認証・チャレンジ、TXTレコード、キーサムプリントなど、さらに多くの手順が控えていることを認識すること
  • 既存クライアントの一部にはpublicExponentのエンコードミス(10進と16進の混同)などのバグが存在することを指摘すること
  • ACMEプロトコルの複雑さは「ジョブセキュリティ」のためかもしれないという皮肉な感想を述べること
  • 仕様や実装の細部にこだわる場合、独自実装は大変だが学びも多い提案

Hackerたちの意見

ACMEやその多くのクライアントに対する攻撃的なトーンが理解できないんだよね。著者が誰かを考えると、スキルの問題じゃないのは明らかだから、ACME自体やその周りのツールに対する個人的な意見なんじゃないかな。私たちは2019年からいくつかのサイトでLEを使ってるけど、私たちにとっての一番のクライアントはhttps://github.com/do-know/Crypt-LE/releasesだったよ。今年はSectigoのACMEサーバーに対して別の作業をしたけど、le64はあんまり良くなかった。だから、以下を試してみたんだ:- https://github.com/certbot/certbotをGitHub Actionsで使ったけど、環境がロックダウンされててあんまり好きじゃなかった - https://github.com/go-acme/legoはバイナリが大きくて、CLIのデザインは面白かったけど、問題を提起したらメンテナーが結構失礼だった - https://github.com/rmbolger/Posh-ACMEはお気に入りだけど、権限周りの変な問題を解決した後にGHAでcertbotを使うことにした。編集* 読み直したけど、トーンはACMEやクライアントに向けられたものじゃなくて、仕様そのものに対するものだね。ACMEのアイデアは良いけど、実装は悪い。

理解できないものをサーバーで動かさなきゃいけないのは嫌だって人もいるし、その意見には同意するよ。残念ながら、セキュリティはイタチごっこのゲームだから、常に進化していかなきゃいけないし、これはこの分野の性質上仕方ないことだから、誰かを責めることはできないよ(例えば、Play Storeに載るために最新のGoogleサービスと統合しなきゃいけないのとは違って)。少なくとも、自分のACMEクライアントを書くことはできるし、certbotを使わなくてもいいし、自分のものにアクセスできなくなるようなTPM的な動作もないからね。

ACMEやその多くのクライアントに対する攻撃的なトーンが理解できない。 同じウェブサイトの古い投稿が、今日の投稿を理解するためのもう少しの文脈を提供してくれたよ: - "なぜ私はまだ古い証明書をhttpsサイトで使っているのか" - 2023年1月3日 - https://rachelbythebay.com/w/2023/01/03/ssl/ - "証明書を発行するためのステップを再考する" - 2023年1月4日 - https://rachelbythebay.com/w/2023/01/04/cert/

ACMEやその多くのクライアントに対する攻撃的なトーンが理解できない。 > ACMEのアイデアは良いけど、実装は悪い。もしかしたら読み違えてるかもしれないけど、あなたは著者と似たような考えを持ってるみたいだね。彼らが記事の冒頭で言ってたように: > 既存のクライアントの多くは怖いコードだし、私は自分のマシンでそれらを動かすつもりはなかった。彼らは私のプライベートキーやウェブサーバー(ルートとして!)を無責任に扱う権利を得ていない。この意見は厳しく感じるかもしれないけど、セキュリティに敏感なプロセスを運営する上ではかなり公平な視点だと思うよ。

自分でやってみる経験のためにこういうものを手動で実装するのも意味があるけど、著者のトーンからはプロトコルが嫌いで、Let's Encryptのセットアップを動かすためにやらなきゃいけない余計な作業が嫌だって感じがする。軽量のACMEライブラリ(https://github.com/jmccl/acme-lwが思い浮かぶけど、ESP32用のもっと軽量なC++ライブラリもあるはず)で証明書を読み込むのがうまくいかないって、彼女のウェブサイトがどんなスタックで動いてるのか気になるね。

でも、著者のトーンからは、彼女がプロトコルやLet's Encryptの設定をうまく動かすために必要な余計な作業を嫌っているように聞こえる。問題は、SSLが本当にクソみたいな、硬直した混乱だってこと。特にエンコーディングやビットフィールド周りの変な問題は、ASN.1/X.509の歴史的な負担によるものだ。これに対処するのは全然楽しくない…数学だけでも十分厄介なのに、様々な数学のための古い抽象は、80年代後半の技術的な制約に縛られている。LetsEncryptの導入で少なくとも部分的に混乱を減らすチャンスがあったはずなのに、基本的には必要な数学の値をちゃんとした形でプロトコルが送信して、x.509証明書を返してもらうってことなんだけど、それは一からいろいろ作り直さなきゃいけなかったから実現しなかった。だから、実際にはACME CAを構築するのは、要するに数行のシェルスクリプトとOpenSSL、そしてOpenSSLの扱いにイライラしながら飲む高アルコールの酒があればできちゃうんだよね。

私はHTTP専用のブログを運営してるけど、毎年HTTPSに切り替えるのが難しくなってきてる。例えば、WhatsappはもうHTTPリンクを開けなくなったし。

プロキシを使うことができるけど、小さなサーバーにとっては、プロキシでキャッシュすることで重いトラフィックを避けるのが一番の方法かもしれないね。

くそったれ、ACMEがどんなに複雑でも、TLSをサポートしないよりはマシだよ。

著者がこの問題を指摘してくれて感謝してる。ウェブが構築されているプロトコルの複雑さが増しているのは、単にプロトコルを使うためのツールやクライアントを見つける必要がある開発者には問題じゃないけど、インターネットを運営するために必要な仕様を満たせるのは既存のプレイヤーだけになるという一種の規制キャプチャだと思う。ACMEだけが乗り越えられないほど複雑ではないことは知ってるけど、これは壁の中のもう一つのレンガだね。

これらのプロトコルにはすべてオープンソースの実装があるよ。そしてAIが強くなるにつれて、この障壁はどんどん小さくなっていくね。

そう、だから「e」が「65537」に等しいって言う代わりに、「e」が「AQAB」に等しいって言ってるんだよね。余計な手間をかけてよかったと思わない?ああ、JSON。理由を知らない人のために言うと、JSONパーサーは数字を正しく扱うとは限らないんだ。4723476276172647362476274672164762476438は有効なJSONの数値?もちろん、そうだよ。じゃあ、JSONパーサーはそれをどうするかって?静かに64ビットまたは63ビットの整数、あるいは浮動小数点数に切り捨てるか、運が良ければエラーを出すか(普通の言語で書かれた良いJSONデコーダーは当然その数字を返すけど、そんなラッキーな人は少ない)。だから、大きな整数をJSONに入れたり出したりするには、別の形でエンコードするのが唯一の確実な方法なんだ。Base64エンコードされたビッグエンディアンのバイトは悪くない選択肢だよ。静かに間違ったことをするのが多くのセキュリティエラーの根源だから、プロトコル内のすべての数値をこのように扱うのは間違ってない。もちろん、そうするとJSONの可読性は失われるけどね。JSONはXMLよりはマシだけど、実際にはあまり良くない。標準的なS式の方がずっと好ましかったけど、何らかの理由で世界はそうならなかったんだ。

大きな整数は、特定のエンディアン順のバイト値のベクターとして常に伝達できるみたいで、Base64よりも扱いやすい。なぜなら、JSONパーサーは少なくともテキストからバイナリにバイト値を変換してくれるからね。でも、Clojureの人間としては、sexprsやEDNの方がずっと良いよね。

rosettacode.orgでS式のタスクを始めた者として、賛成だよ。もしあなたの言語用のS式パーサーが必要なら、ここを見てね https://rosettacode.miraheze.org/wiki/S-expressions (標準のURLは https://rosettacode.org/wiki/S-expressions だけど、今はDNSの問題があるみたい)。

「悪い方が良い」っていうのは、今でもすごい成功を収めてるね。

俺が理解できないのは、なんで君(や他の多くの人)がS式パーサーが全く同じ問題を抱えてないと期待するのかってことだ。

Goは数字を文字列としてロスレスでデコードできるよ:https://pkg.go.dev/encoding/json#Number json.Numberは(ほぼ)俺の「お気に入り」の任意の小数だよ:https://github.com/ncruces/decimal?tab=readme-ov-file#decima... 半分冗談だけど、ここでS式がどうして良いのかはよくわからないな。任意の精度の数学をやらないLISPもあるし。

でも、数字を文字列として送るのが何が悪いの? "65537"の代わりに"AQAB"で。

これで大丈夫? Python 3.13.3 (main, 2025年5月21日, 07:49:52) [GCC 14.2.0] on linux 詳細については "help", "copyright", "credits" または "license" と入力してください。 >>> import json >>> json.loads('47234762761726473624762746721647624764380000000000000000000000000000000000000000000') 47234762761726473624762746721647624764380000000000000000000000000000000000000000000

標準的なS式の方がはるかに好ましかったけど、何らかの理由で世界はそうならなかった。JSONが勝った理由を理解できないのは、わざと理解しようとしていないように感じる。JSONはほとんどのデータに対して手書き、編集、読み取りが簡単にできる。標準的なS式は読みやすくなく、手書きするのがずっと難しい。すべての原子に長さをプレフィックスする必要があるので、手書きするのが非常に面倒になる。手で編集したいJSONオブジェクトがあれば、ただタイプすればいいけど、標準的なS式の場合は、何文字をタイプ/削除しているかを数えて、プレフィックスを更新しなければならない。手で生成、読み取り、編集する能力が重要だとは思わないかもしれないけど、それがJSONが最終的に勝った大きな理由だと私は確信している。あ、RubyのJSONパーサーはその大きな数をうまく処理しますよ。

あなたが言った通り、JSONの構造やフォーマット自体に問題があるわけじゃなくて、初期のjsタイプにマッピングするように特別に設計された基盤のパーサーに問題があるんだよね。この問題がないパーサーもあるけど、その場合JSON自体はポータブルじゃない。あなたの解決策の問題は、同じ理由でポータブルじゃないってこと(標準の一部じゃないから)で、最初からそうしなかった理由は、初期のjsタイプにマッピングできなかったからなんだ!ちなみに、replacerやreviverを使えば簡単に回避できるよ、それらはstringifyやparseの標準の一部だから、数字を違う扱いにすることができる。でもやっぱり、そのreplacer/reviverがない場所ではjsonはポータブルじゃない。つまり、本当の問題は、標準に準拠したjsonパーサーを使って、jsonのように見えるものをjsonとして扱うことなんだよね。フォーマット自体の見かけの構造じゃなくて。これをJSON以外の何かと呼べば一瞬で解決できるけど、人々はそれを見て、JSONのように見えるからJSONパーサーを使うんだよね、JSONだからじゃなくて。

これはちょっとしたお話みたいだね。もし{"e" : "AQAB"}と{"e" : 65537}を比較してるなら、あなたの説明は少しは理解できるかもしれないけど、それが代替案である理由はないよ。JSON {"e" : "65537"}は、どんなJSONパーサーでも正確に同じように読み取られるからね。「65537」という文字列を数字の65537に変換するのは、同じ数字に「AQAB」という文字列を変換するのと全く同じくらい簡単(または難しい)で、もちろんあいまいさはないよ。もちろん、これをJSでやっていて、結果の数字がダブルの精度を超えるかもしれないと思うなら、どちらにしても大きな問題があるよね。Cでこれを書いていて、その数字がlong longに収まる以上の大きさかもしれないと思っても同じことが言えるけど、それはJSONでどう表現するかに関係なく真実なんだ。

ACMEクライアントを一から実装したいなら、RFC(JOSEなどの関連RFCも含めて)を読むのは思っているより簡単だよ。自分用にクライアントを作ったとき、まさにそれをやったんだ。発行フローの要約もここに書いたよ: https://www.arnavion.dev/blog/2019-06-01-how-does-acme-v2-wo... RFCを読む代わりにはならないけど、発行のために従うべき情報をその順序で提示しているから、RFCセクションのインデックスみたいに考えてもらえればいい。

マニュアルを読むより、実装を平易な英語でコードなしで書き直してハッカーニュースに投稿した方がいいじゃん?インターネットポイントがめっちゃ増えるよ!

ACMEクライアントを実装するのはMITのセキュリティクラスの最終実習の一部だよ:https://css.csail.mit.edu/6.858/2023/labs/lab5.html

OpenBSDには、ベースOSの一部として超シンプルで軽量なACMEクライアント(Cで書かれている)があるんだ。自分で作る必要はないよ。既存の代替品がバloatwareで、彼らのUnix的な哲学に反しているから作られたって聞いたけど、作者があまり探してなかったのかもね。たぶん、ちょっとした努力でポートできると思うよ。

それかuacme [0] - ちょっとしたCで、LEのPythonクライアントのバッテリーが無限に失敗してた時から完璧に動いてるよ。長持ちするものを探してたんだ。[0] https://github.com/ndilieto/uacme

うん、これについてコメントしてくれる人を探してたんだ。俺も使ってるけど、めっちゃ良いよ。

最後に確認したとき、このクライアントはOpenBSDの哲学がセキュリティの理由を理解していない典型的な例だと思った。クライアントは、本当に簡単なケースを望んでいて、クライアントが名前を持つマシン上で動作し、ウェブサーバーを運営している。そして、OpenBSD特有のパーティショニングを使用して、クライアントの要素が欠陥があった場合でもお互いに影響を与えにくくしている。でも、ACMEプロトコルは実際のエアギャップを許可するんだよね。このプロトコルは、証明書が必要なマシン、ACMEクライアントを実行しているマシン、名前を制御しているマシンが3つの別々のマシンであっても気にしないから、それが問題ない。だから、このOpenBSDのオールインワンクライアントを使わなければ、ACMEを全く使わないウェブサーバーや、ウェブページを提供する権限がないACMEクライアントマシン、ACMEについて何も知らないネームサーバーを持っていても、全体のシステムは機能するんだ。これは「ただOpenBSDをインストールする」よりも手間がかかるけど、セキュリティを提供するためにこう設計されているんだよね。

私はLet’s EncryptのSRE/インフラチームの技術リードです。だから、これについて考える時間がたくさんあります。この塩は正当なものです!JSON Web Signaturesは複雑なフォーマットで、ACME APIはRESTfulであることにかなり熱心です。私がデザインするものではありません。多くはIETFが他のIETF標準を使いたがったことと、委員会によるデザインの影響があると思います。いくつかのライブラリ(JWS、JSON、HTTP用)は、より快適にするために大いに役立ちますが、それらのライブラリ自体が常に良いわけではなく、特にCではそうです。私はここでも助けるためにインタラクティブなクライアントとそれに伴うドキュメントを作成しています。RFCの言語は少し難解で、他の文書を参照することも多いですからね。

彼女が言っているのは、3つ以上の証明書が欲しいならお金を払わなきゃいけないってこと?過去5年間の請求書が来るの?それとも彼女が誤解しているだけ?

JSON Web Signaturesは複雑なフォーマットだって? そうなの?ASN.1、Kerberos、PKIにどっぷり浸かっている私としては、JWSがそんなに「複雑」だとは思わない。JSON Web Signatureをオープンコーディングするのは、S/MIMEやCMS、Kerberosなどをオープンコーディングするよりも簡単だと思う。JWSの何がそんなに複雑なのか説明してもらえる?JWTには問題があるのは確かだけど。主に、HTTPユーザーエージェントがそれを取得する方法を知らないから、どうやって取得するかの標準がないし、リクエストを尊重すべきタイミングもわからないから。

4096ビットのRSAキーを作成しなさい。それをあなたの個人キーと呼びなさい。これは悪いアドバイスだよ - 4096ビットのキーを作ると、あなたのウェブサイトの訪問者が遅くなって、実際には2048ビットのセキュリティしか提供しない(2048ビットのRSAキーを破ることができるなら、LetsEncryptの中間証明書も破れるし、あなたのサイトをMITMできる)。ここでは2048ビットのリーフ証明書を使うべきだよ。

アマチュアな質問だけど:4096ビットはパッシブキャプチャや将来の復号に対してもっとセキュリティを提供するの?それとも中間証明書もそのような非同期攻撃に影響するの?

私のウェブホスティングはRSAキーしかサポートしてないから、ECキーをサポートさせるためにRSA-4096キーを使ってるんだよね。ちょっとイラッとさせるためにね。

「今のところ、私たちは(少なくとも):RSAキー、SHA256ダイジェスト、RSA署名、実際にはあまり実用的でないbase64、文字列の連結、JSONの中のJSON、301レスポンスのターゲットとして使われるLocationヘッダー、ヘッダーとして埋もれた単一の値を取得するためのHEADリクエスト、他のリクエストをするためのリクエスト(nonce)、そしてまだまだ続く予定です。オーダーの作成、認証やチャレンジの処理、「キーのサムプリント」みたいなこと、TXTレコードに実際に何が入るのか、その他の楽しいことについてはまだ全然触れていません。」うわぁ、信じられないくらい複雑だね。すごい混乱だ。労力の成果をシェアしてくれてありがとう。素晴らしい文体と内容だね。