Go言語でLet's EncryptのACMEを理解する

by Taichi Nakashima,

Let’s Encrypt

TL;DR

Let’s EncryptのベースのプロトコルであるACMEを理解する.

まずACMEをベースとしたCAであるboulderをローカルで動かす.次にACMEのGo言語クライアントライブラリであるericchiang/letsencrypt(非公式)を使い実際にboulderと喋りながら証明書発行を行い,コードとともにACMEが具体的にどのようなものなのかを追う.

はじめに

証明書というのは面倒なもの,少なくともカジュアルなものではない,というイメージが強い.それは有料であることや自動化しにくいなどといったことに起因している(と思う).そのようなイメージに反して近年登場する最新の技術/プロトコルはTLSを前提にしているものが少なくない(e.g., HTTP2).

このような背景の中で登場したのがLet’s Encryptと呼ばれるCAである.Let’s Encryptは上で挙げたような問題(煩雑さ)を解決しようとしており,無料・自動・オープンを掲げている(cf. “Let’s Encrypt を支える ACME プロトコル”).最近(2015年12月3日)Public Betaがアナウンスされすでに1日に70kの証明証が発行され始めており(cf. Let’s Encrypt Stats)大きな期待が寄せられている.特に自分は仕事で多くのドメインを扱うのでLet’s Encryptは使ってくぞ!という意識がある.

Let’s EncryptはDV証明書を発行することができるCAである.DV証明書とはドメインの所有を確認して発行されるタイプの証明書である.Let’s Encryptの大きな特徴の1つに自動化が挙げられる.申請からドメインの所有の確認,証明書発行までは全てコマンドラインで完結させることができる.そしてこのフローはLet’s Encrypt以外のCAでも利用できるように標準化が進められている.これはAutomated Certificate Management Environment(ACME)プロトコルと呼ばれる(ちなみにLet’s encryptの証明証の有効期限は90日である.これはセキュリティ強化の面もあるが自動化の促進という面もある(cf. Why ninety-day lifetimes for certificates?)).

Let’s Encryptは専用のACMEクライアントを提供している(letsencrypt).基本はこれを使えば証明書の発行や,Apacheやnginxの設定ファイルの書き換え(!)などができる(やりすぎ感が気にくわないと感じるひとが多いようでsimple alternativeがいくつか登場している…).

それだけではなくACMEベースのCA(つまりLet’s encrypt)はBoulderとう名前でOSSベースで開発されている(Go言語で実装されている).つまりBoulderを使えば誰でもACMEをサポートしたCAになることができる.

本記事ではおそらく将来的には意識しないでよくなる(であろう)ACMEプロトコルがどのようなものかを理解する.boulderをローカルで動かし(Dockerfileが提供されている),非公式であるがGo言語のACMEクライアントericchiang/letsencryptを使ってACMEを喋ってみる.

なおACMEはまだ仕様策定中なので以下の説明は変更される可能性がある.

boulderを動かす

まず準備としてboulderを動かす.今回は例としてexample.orgの証明証を発行する.ローカルでこれを実行するためには以下の準備が必要になる.

  • cmd/policy-loader/base-rules.jsonのブラックリストからexample.orgを外す
  • /etc/hostsを編集してexample.org127.0.0.1に向ける

完了したらboulderコンテナを起動する.

$ cd $GOPATH/src/github.com/letsencrypt/boulder/
$ ./test/run-docker.sh

ACMEの概要

ACME spec draft

ACMEとは「クライアントのドメインの所有を確認して証明書を発行する」ためのプロトコルであった.これをさらに細かくブレイクダウンすると以下の操作から構成される.

  • 各操作を行うためのURIを知る(directory)
  • クライアントの登録を行う(new-registration)
  • 認証(ドメイン所有の確認)を行う(new-authorization)
  • 証明書(Certification)を発行する(new-certificate)

ACMEはこれらのリソースを持ったRESTアプリケーションであるとみなすこともできる.各リソースはその上のリソースに依存しており,上から順番にリクエストをこなしていくことで最後の証明書の発行に到達することになる.

以下ではこれらのリソースをさらに細かく見ていく.

directory

directoryは他の各種リソースのURIをクライアントに提示する.クライアントはまずここにリクエストしその後の操作でリクエストするべきendpointを知る.

ericchiang/letsencryptを使うと以下のようになる.

client, err := letsencrypt.NewClient("http://localhost:4000/directory")
if err != nil {
    log.Fatal(err)
}   

NewClientはdirectoryにリクエストを投げ以下の操作で必要なendopointを保持したclientを作成する.

new-registration

new-registrationはクライアントの登録を行う.具体的にはRSAもしくはECDSAの公開鍵を登録する.ここで登録した鍵は以後のすべてのリクエストで利用する.ここではRSAを利用する.以下で事前に生成しておく.

$ ssh-keygen -t rsa -b 4096 -C "[email protected]" -f letsencrypt-test -N ''

コードは以下のようになる.

client, err := letsencrypt.NewClient("http://localhost:4000/directory")
if err != nil {
    log.Fatal(err)
}

data, err := ioutil.ReadFile("letsencrypt-test")
if err != nil {
    log.Fatal(err)
}

block, _ := pem.Decode(data)
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
    log.Fatal(err)
}

if _, err := client.NewRegistration(key); err != nil {
    log.Fatal(err)
}

log.Println("Registered")

事前に生成した鍵ファイルを読み込みデコードしてNewRegistrationを呼ぶ(cf. Go言語と暗号技術(AESからTLS)に書いた).これで登録が完了する.

この鍵はnew-registrationだけではなくこの後の各リクエストの署名とその検証にも利用される.具体的にはクライアントはリクエストするJSONをJWSという仕様に基づき署名する.そしてサーバーはリクエストを受けると処理を始める前にそのJWSによる署名の検証を行う.

ちなみに公開鍵はどのようにサーバーに送られるのか? これにはJWKという仕様がありそれに基づき送信される(JWSやJWKといったJWxの技術に関しては@lestrratさんのブログ記事“GoでOAuth2/OpenIDとJOSE (JWA/JWT/JWK/JWS/JWE)"が詳しい).

new-authorization

new-authorizationではドメインの所有の確認を行い認証を行う.具体的にそのドメインの所有をどのように確認するのか? それにはドメインの所有者にしかできない特定の操作を行わせることで確認を行う.ACMEではこの操作をChallengeと呼ぶ.

現在(2015年12月)Challengeにはhttp-01tls-sni-01といったものがある.例えばhttp-01はクライアントのサーバー上の特定のパスに指定された内容のテキストファイルを配置させ,そこにアクセスし予期するファイルが配置されているかで確認を行う.

認証は以下のような流れで行われる.

  1. クライアントはnew-authorizationリソースにPOSTリクエストを送る(POSTリクエストのBodyにはJWSが含まれていなけばならない)
  2. サーバーは利用可能なChallengeとそのうち達成するべき組み合わせ(複数のChallengeの達成を要求することもできる)を返答する
  3. クライアントはChallengeに応える
  4. サーバーはChallengeの達成を確認する

コードで書くと以下のようになる.まずChallengeの取得を行う(clientの初期化とkeyの読み込みは完了しているとする).

auth, _, err := client.NewAuthorization(key, "dns", "example.org")
if err != nil {
    log.Fatal(err)
}

log.Println("[INFO] Challenges:")
for _, challenge := range auth.Challenges {
    log.Println("  ", challenge.Type, challenge.URI)
}

var combs string
for _, comb := range auth.Combs {
    combs += fmt.Sprintf("%v ", comb)
}
log.Println("[INFO] Combinations:", combs)

これを実行すると以下のような結果が得られる.

2015/12/05 17:16:44 [INFO] Challenges:
  dns-01 http://127.0.0.1:4000/acme/challenge/FQ01uZjCoY13Z5Jak7..
  tls-sni-01 http://127.0.0.1:4000/acme/challenge/FQ01uZjCoY13Z5..
  dvsni http://127.0.0.1:4000/acme/challenge/FQ01uZjCoY13Z5Jak7H..
  http-01 http://127.0.0.1:4000/acme/challenge/FQ01uZjCoY13Z5Jak..
  simpleHttp http://127.0.0.1:4000/acme/challenge/FQ01uZjCoY13Z5..
  
2015/12/05 17:16:44 [INFO] Combinations: [0] [1] [2] [3] [4]

現在のboulderのdevモードは4つのChallengeを返す(simpleHttpdvsniはdeprecatedなので無視してもよい #231).challenge.URIは具体的なChallengeに必要となる情報(例えばhttp-01の場合はサーバーにアクセスさせるためのパスとそこに配置するリソース)を取得するためのendpointである.そして組み合わせ(Combs)は指定されておらずどれか1つでも達成すればよい.

次に実際にChallengeを達成する.ここではhttp-01を達成する.

auth, _, err := client.NewAuthorization(key, "dns", "example.org")
if err != nil {
    log.Fatal(err)
}

var httpChallengeURI string
for _, challenge := range auth.Challenges {
    if challenge.Type == "http-01" {
        log.Println("[INFO]", challenge.Type, challenge.URI)
        httpChallengeURI = challenge.URI
    }
}

if httpChallengeURI == "" {
    log.Fatal("httpChallengeURI should not be empty")
}

challenge, err := client.Challenge(httpChallengeURI)
if err != nil {
    log.Fatal(err)
}

b, err := json.MarshalIndent(&challenge, "", "  ")
if err != nil {
    log.Fatal(err)
}
log.Println("[INFO]", string(b))

path, resource, err := challenge.HTTP(key)
if err != nil {
    log.Fatal(err)
}

go func() {
    http.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
        io.WriteString(w, resource)
    })

    // The test Let's Encrypt server uses port 5002 instead of 80.
    if err := http.ListenAndServe(":5002", nil); err != nil {
        log.Fatal(err)
    }
}()

if err := client.ChallengeReady(key, challenge); err != nil {
    log.Fatal(err)
}

log.Println("[INFO] Complete challenge!")

以下のようなことをしている.

  1. サーバーが受け付け可能なChallengeをリクエストする(NewAuthorization
  2. http-01を選択し具体的なアクションのために必要となる情報をリクエストする(Challenge
  3. 上のレスポンスからhttp-01に必要なサーバーからリクエストされるpathとそのresourceを取得する(HTTP
  4. pathにてresourceをserveするようにサーバーを起動する(goroutine)
  5. Challgenが準備できたことをサーバーに伝えValidateしてもらう(ChallengeReady

これで認証は完了する.あとはcsrを送れば証明書を取得することができる.

new-certification

new-certificationは新しい証明書の発行を行う.

まず.csrファイルを作成する..csrの作成はopensslコマンドなどでも可能だがここではGo言語で作成する.Go言語で証明書を操作するにはx509パッケージを使えばよい(詳しくはGo言語と暗号技術(AESからTLS)).コードは以下.RSAを使う.

certKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
    log.Fatal(err)
}

template := x509.CertificateRequest{
    SignatureAlgorithm: x509.SHA256WithRSA,
    PublicKeyAlgorithm: x509.RSA,
    PublicKey:          &certKey.PublicKey,
    Subject:            pkix.Name{CommonName: "example.org"},
    DNSNames:           []string{"example.org"},
}

if err != nil {
    log.Fatal(err)
}

csrOut, err := os.Create("example.org.csr")
if err != nil {
    log.Fatal(err)
}
defer csrOut.Close()

if err := pem.Encode(csrOut, &pem.Block{
    Type:  "CERTIFICATE REQUEST",
    Bytes: csrDerByte,
}); err != nil {
    log.Fatal(err)
}

keyOut, err := os.Create("example.org.key")
if err != nil {
    log.Fatal(err)
}

if err := pem.Encode(keyOut, &pem.Block{
    Type:  "RSA PRIVATE KEY",
    Bytes: x509.MarshalPKCS1PrivateKey(certKey),
}); err != nil {
    log.Fatal(err)
}

これでexample.org.csrexample.org.keyが生成できる.

次に証明書の発行を行う.コードは以下(clientの初期化とkeyの読み込みは完了しているとする).

csrData, err := ioutil.ReadFile("example.org.csr")
if err != nil {
    log.Fatal(err)
}

csrBlock, _ := pem.Decode(csrData)
csr, err := x509.ParseCertificateRequest(csrBlock.Bytes)
if err != nil {
    log.Fatal(err)
}

cert, err := client.NewCertificate(key, csr)
if err != nil {
    log.Fatal(err)
}

certOut, err := os.Create("example.org.crt")
if err != nil {
    log.Fatal(err)
}

if err := pem.Encode(certOut, &pem.Block{
    Type:  "CERTIFICATE",
    Bytes: cert.Raw,
}); err != nil {
    log.Fatal(err)
}

log.Println("[INFO] Successfully issued")

上で生成した.csrを読み込みNewCertificateを呼ぶだけ.簡単.

証明書の検証

最後に証明書の検証を行う.Go言語で証明書の検証は以下のように書ける(boulderのDevモードの場合はtestディレクトリ以下にtest-ca.pemがあるのでそれを使う).

caData, err := ioutil.ReadFile("./test-ca.pem")
roots := x509.NewCertPool()
if ok := roots.AppendCertsFromPEM(caData); !ok {
    log.Fatal("Failed to parse ca pem")
}

certData, err := ioutil.ReadFile("./example.org.crt")
certBlock, _ := pem.Decode(certData)
cert, err := x509.ParseCertificate(certBlock.Bytes)
if err != nil {
    log.Fatal(err)
}

opts := x509.VerifyOptions{
    DNSName: "example.org",
    Roots:   roots,
}

if _, err := cert.Verify(opts); err != nil {
    log.Fatal(err)
}

log.Println("[INFO] Verified !")

まとめ

本記事ではACMEをベースとしたCAであるboulderをローカルで動かし,ACMEのGo言語クライアントライブラリを使いながらACMEの詳細を追ってみた.Webの基本の技術を組み合わせているだけなので特に複雑ではない.200行のpythonで書かれたACMEツールなどもある(cf. diafygi/acme-tiny)のでいろいろ探ってみたら面白いと思う.

どんどんTLSにしていくぞ!

参考