Help us understand the problem. What is going on with this article?

ブラウザで動く Let's Encrypt クライアントを作ってみた

(2019/01/21 追記)
更新漏れで失効させてしまっていたドメイン名「sslnow.ml」が解放されたため再取得しました。
これに伴い記事中の URL を再び「sslnow.cf」から「sslnow.ml」に戻しています。
来年以降は気をつけます。。

(2018/12/27 追記)
これまで使用していたドメイン名「sslnow.ml」を更新漏れで失効させてしまったため、新たに「sslnow.cf」を登録しました…。
これに伴い記事中の URL を「sslnow.ml」から「sslnow.cf」に変更しています。

(2016/07/09 追記) dns-01 と ECDSA に対応しました!
「Let's Encrypt の証明書をブラウザ上で簡単取得 (dns-01 / ECDSA もあるよ)」

はじめに

無料で SSL 証明書を取得できる Let's Encrypt が 2015/12/03 に Public Beta となり、誰でも試せるようになりました。
公式の Python スクリプトが用意されていてコマンド一発で証明書が取得可能!
…なのはいいのですが、公式スクリプトにはいくつか気になる点も。

  • sudo されて /etc/letsencrypt 以下にもろもろ生成される
  • サーバ秘密鍵も自動で生成される
  • 一発コマンドは対象ドメイン名の A レコードで指定されているサーバ上で実行する必要がある
  • サーバの OS や環境によっては公式スクリプトが動作しない

などなど。
証明書の更新まで自動化することが Let's Encrypt の目的なので
上記のような公式スクリプトになるのはわかるのですが、
ちょっと試してみるにはなかなか難しい面もあります。

すでにいろんな言語で非公式クライアントはたくさん出ていますが、
せっかくなので Let's Encrypt が利用する証明書発行の仕組み 「ACME」 の勉強がてら
公式スクリプトの manual モードに相当するクライアントを
ブラウザだけで動作するように作ってみました。

Let's Encrypt クライアント「SSLなう!」で証明書取得

というわけで、まずは実際に証明書を取得してみましょう!

SSLなう! (https://sslnow.ml/)

「SSLなう!」の特徴は以下のような感じです。

  • 証明書発行までほぼブラウザのみで可能 (ドメイン名所有権確認のための指定コンテンツ設置を除く)
    • 最近のブラウザが動作すればどの OS でも OK
  • サーバ秘密鍵はブラウザ上での生成と既存鍵の持ち込みの両方に対応
  • SAN (Subject Alternative Name) による複数ドメイン名への証明書発行に対応
  • 証明書発行のための通信はすべて ブラウザ と Let's Encrypt サーバ の間で直接行われる
  • 生成した申請用の署名鍵・連絡先はブラウザの localStorage に保存

ACME クライアントとしての動作

(最新の公式情報は https://github.com/ietf-wg-acme/acme/ を参照してください。)

おおまかに次のような流れで証明書を取得します。

  1. ACME サーバの各機能の URL の取得
  2. 署名鍵と連絡先の登録
  3. 各ドメイン名ごとのチャレンジトークンの取得
  4. チャレンジトークンを使用したドメイン名所有権の確認
  5. 証明書発行要求

またデータを送信する際には nonce と JWS にも留意する必要がありますので後述します。

では「example.com」の証明書を取得する際の流れを見ていきましょう。

1. ACME サーバの各機能の URL の取得

まずは各種要求を送信する URL を取得します。
この最初のディレクトリ URL はあらかじめクライアントが知っておく必要があります。
Let's Encrypt の場合 https://acme-v01.api.letsencrypt.org/directory なので、
素直に GET すると…

request
GET 'https://acme-v01.api.letsencrypt.org/directory'

エンドポイントの入った JSON が得られます。

response
200 OK
{
  new-authz   : "https://acme-v01.api.letsencrypt.org/acme/new-authz",
  new-cert    : "https://acme-v01.api.letsencrypt.org/acme/new-cert",
  new-reg     : "https://acme-v01.api.letsencrypt.org/acme/new-reg",
  revoke-cert : "https://acme-v01.api.letsencrypt.org/acme/revoke-cert"
}

2. 署名鍵と連絡先の登録

証明書発行を要求する前に、申請者の署名鍵と連絡先メアドの登録が必要です。
「SSLなう!」では RSA 2048 bit の署名鍵を生成しています。
メアドはなくてもいいのですが、
鍵をなくした際などのリカバリ手段として登録しておいた方が良いと思います。
またこのときに利用規約への同意があることも送信しておきます。
送信先 URL は先ほど取得した「new-reg」の URL です。

これ以降、説明では JSON を直接 POST するように書いていますが、
実際には申請者の署名鍵を使って JWS で署名したものを送信しています。
これについてはまとめて後述します。

request
POST 'https://acme-v01.api.letsencrypt.org/acme/new-reg'
{
  resource  : "new-reg",
  contact   : ['mailto:hoge@examle.com'],
  agreement : "https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf"
} /* Signed as JWS */

登録に成功すると下記のような応答が得られます。
応答には特に署名等は含まれておらず、素の JSON です。

response
201 OK
{
  agreement : "https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf", /* 同意した規約 */
  contact   : ["mailto:hoge@example.com"],     /* 連絡先 */
  createdAt : "2015-12-31T00:00:00.00000000Z", /* 登録日時 */
  id        : 0000000,                         /* 登録番号 */
  initialIp : "192.0.2.1",                     /* 送信者の IP */
  key       : {                                /* 申請者の公開鍵 (JWK 形式) */
    e         : "AQAB",                          /* publicExponent を Base64url Encode したもの */
    kty       : "RSA",                           /* 鍵タイプ */
    n         : "hogehogefugafuga....(略)"       /* modulus        を Base64url Encode したもの */
  }
}

一度署名鍵の登録ができれば次回以降はこのステップは不要です。

3. 各ドメイン名ごとのチャレンジトークンの取得

申請者の鍵が登録できたら、証明書発行対象の各ドメイン名について
所有権があることを証明するためのトークンを取得します。
複数ドメイン名の証明書を発行する際は、各ドメイン名ごとに POST を実行します。
送信先 URL は「new-authz」です。

request
POST 'https://acme-v01.api.letsencrypt.org/acme/new-authz'
{
  resource   : "new-authz",
  identifier : {
    type       : "dns",
    value      : "example.com" /* 証明書発行対象のドメイン名 */
  }
} /* Signed as JWS */

ドメイン名等に問題が無ければ利用可能な証明方法とトークン・URI が取得できます。

response
201 OK
{
  identifier : {
    type       : "dns",
    value      : "example.com"
  },
  status     : "pending",
  expires    : "2016-01-01T00:00:00.000000000Z",
  challenges : [
    {
      type     : "http-01",
      status   : "pending",
      uri      : "https://acme-v01.api.letsencrypt.org/acme/challenge/hogehogefugafuga/000000",
      token    : "piyopiyopiyopiyo"
    },
    {
      type     : "tls-sni-01",
      status   : "pending",
      uri      : "https://acme-v01.api.letsencrypt.org/acme/challenge/hogehogefugafuga/000001",
      token    : "poyopoyopoyopoyo"
    }
  ],
  combinations : [ /* 有効な challenge の組み合わせ (いずれかの組み合わせを満たせば OK) */
    [0],             /* http-01 のみ */
    [1]              /* tls-sni-01 のみ */
  ]
}

現在のところ、Let's Encrypt では「http-01」と「tls-sni-01」に対応しているようです。

今回は、対象ドメイン名の WEB サーバ上に token を含むファイルを設置する「http-01」を利用します。

4. チャレンジトークンを使用したドメイン名所有権の確認

「http-01」で所有権の確認を行うためには、
http://(対象ドメイン名)/.well-known/acme-challenge/(上記で得たtoken)
という URL へのアクセスに対し、応答として
(前項で得たtoken).(申請者の署名鍵 (JWK 形式) の SHA-256 ハッシュ値を Base64url encode したもの)
を返すようにすれば OK です。

したがって今回の場合、
http://example.com/.well-known/acme-challenge/piyopiyopiyopiyo
に既定の応答を含むファイルを設置します。

設置が完了したら、前項の応答で得られた uri に下記を POST し、
Let's Encrypt 側からの確認を要求します。

チャレンジトークンを取得するたびに、またそれぞれのチャレンジタイプごとに uri が異なるので、
今回取得した応答の中で希望するチャレンジの uri にリクエストを送信します。

request
POST 'https://acme-v01.api.letsencrypt.org/acme/challenge/hogehogefugafuga/000000'
{
  resource         : "challenge",
  type             : "http-01",
  keyAuthorization : "piyopiyopiyopiyo.foobarfoobar", /* `(token).(署名鍵 (JWK 形式) の SHA-256 値を Base64url encode したもの)` */
  token            : "piyopiyopiyopiyo"
} /* Signed as JWS */

下記のような応答が返ってくれば、通常数秒ほど後に Let's Encrypt 側から Web サーバへの確認が行われます。
status 以外は送信した内容そのままです。

response
202 OK
{
  type             : "http-01",
  status           : "pending",
  uri              : "https://acme-v01.api.letsencrypt.org/acme/challenge/hogehogefugafuga/000000",
  token            : "piyopiyopiyopiyo",
  keyAuthorization : "piyopiyopiyopiyo.foobarfoobar"
}

確認要求が受理されたら進捗状態を確認します。
先ほどの uri を GET すると現在の進捗状態が得られます。

request
GET 'https://acme-v01.api.letsencrypt.org/acme/challenge/hogehogefugafuga/000000'

「まだ確認中 (pending)」の場合、先ほどと同じ内容が返ってきますので
もう少し待ってから再度 GET で確認しましょう。

response
202 OK
{
  type             : "http-01",
  status           : "pending",
  uri              : "https://acme-v01.api.letsencrypt.org/acme/challenge/hogehogefugafuga/000000",
  token            : "piyopiyopiyopiyo",
  keyAuthorization : "piyopiyopiyopiyo.foobarfoobar"
}

確認に失敗すると status "invalid" が返ってきます。
エラーの内容が error に、確認対象が validationRecord に格納されています。
(下記は対象が 404 だった例です)
一度 invalid となってしまった場合はチャレンジトークンの取得からやり直します。

response
202 OK
{
  type             : "http-01",
  status           : "invalid",         /* invalid になるとチャレンジトークンの取得からやり直しです */
  error            : {
    type             : "urn:acme:error:unauthorized",
    detail           : "Invalid response from http://example.com/.well-known/acme-challenge/piyopiyopiyopiyo [192.0.2.80]: 404"
  },
  uri              : "https://acme-v01.api.letsencrypt.org/acme/challenge/hogehogefugafuga/000000",
  token            : "piyopiyopiyopiyo",
  keyAuthorization : "piyopiyopiyopiyo.foobarfoobar",
  validationRecord : [
    {
      url               : "http://example.com/.well-known/acme-challenge/piyopiyopiyopiyo",
      hostname          : "example.com",
      port              : "80",
      addressesResolved : [            /* 対象ドメイン名の A レコードが複数ある場合は複数表示されます */
        192.0.2.80
      ],
      addressUsed       : "192.0.2.80" /* 上記のうち実際に確認に使用された IP アドレス */
    }
  ]
}

確認に成功すると status "valid" が返ってきます。

response
202 OK
{
  type             : "http-01",
  status           : "valid",
  uri              : "https://acme-v01.api.letsencrypt.org/acme/challenge/hogehogefugafuga/000000",
  token            : "piyopiyopiyopiyo",
  keyAuthorization : "piyopiyopiyopiyo.foobarfoobar",
  validationRecord : [
    {
      url               : "http://example.com/.well-known/acme-challenge/piyopiyopiyopiyo",
      hostname          : "example.com",
      port              : "80",
      addressesResolved : [            /* 対象ドメイン名の A レコードが複数ある場合は複数表示されます */
        192.0.2.80
      ],
      addressUsed       : "192.0.2.80" /* 上記のうち実際に確認が行われた IP アドレス */
    }
  ]
}

証明書発行対象の全ドメイン名について所有権の確認に成功したら、
いよいよ証明書の発行要求です。

5. 証明書発行要求

みなさまおなじみの CSR を POST します。
$ openssl req -new ... で作るアレですね。
複数ドメイン名の証明書を要求する場合は CSR の X509v3 Subject Alternative Name に
対象の全ドメイン名を含めておきます。
送信先は最初に得た「new-cert」の URL です。

request
POST 'https://acme-v01.api.letsencrypt.org/acme/new-cert'
{
  resource : "new-cert",
  csr      : "mogemoge...(略)" /* DER 形式の CSR を Base64url Encode したもの */
} /* Signed as JWS */

これまでのステップと CSR に問題がなければ応答として証明書のバイナリが返ってきます。

response
201 OK
... /* DER 形式のサーバ証明書 (バイナリ) */

このバイナリを Base64 Encode して 64 文字ごとに改行を入れ、
先頭行と最終行に「-----BEGIN CERTIFICATE-----」「-----END CERTIFICATE-----」を挿入すれば
見慣れた pem 形式の証明書のできあがりです。

また応答の「Link」ヘッダに中間証明書を取得できる URL が含まれているので
こちらも GET しておきましょう。

request
GET 'https://acme-v01.api.letsencrypt.org/acme/issuer-cert'

こちらも返ってくるのは DER 形式のバイナリです。

response
200 OK
... /* DER 形式の中間証明書 (バイナリ) */

あとは得られた証明書を Web サーバ等に deploy すれば完了です。
お疲れさまでした:relieved:

nonce について

ACME ではリプレイアタックを防ぐための仕組みが導入されています。
サーバからの応答には必ず「Replay-Nonce」ヘッダが存在し、
クライアントは最後に受け取った Replay-Nonce ヘッダの値をデータ送信時に付加しなければ
リクエストはサーバに受理されません。
実際の付加方法は次項にて。

JWS について

ACME ではサーバへのデータ送信時には署名鍵を用いた JWS での署名が必要となります。

たとえば送信したい内容がpayloadであった場合、
実際に送信されるデータdataは以下のようになります (JavaScript の場合)。

var payload = {
  hoge  : "fuga",
  foo   : "bar"
};

var header = {
  alg   : "RS256",
  jwk   : {
    e     : "AQAB",                /* 署名鍵の publicExponent を Base64url Encode したもの */
    kty   : "RSA",                 /* 署名鍵のタイプ */
    n     : "piyopiyopiyo....(略)" /* 署名鍵の modulus        を Base64url Encode したもの */
  }
};

var protected = {
  alg   : "RS256",
  jwk   : {
    e     : "AQAB",                /* 署名鍵の publicExponent を Base64url Encode したもの */
    kty   : "RSA",                 /* 署名鍵のタイプ */
    n     : "piyopiyopiyo....(略)" /* 署名鍵の modulus        を Base64url Encode したもの */
  }, /* ここまでは header と同じ */
  nonce : 'サーバから受け取った最後の応答の Replay-Nonce ヘッダの値'
};

var b64protected = 'JSON.stringify(protected) を Base64url Encode したもの';
var b64payload   = 'JSON.stringify(payload)   を Base64url Encode したもの';
var signature    = '(b64protected + "." + b64payload) を、署名鍵を使って SHA256withRSA で署名したもの';
var b64signature = 'signature                 を Base64url Encode したもの';

var data = JSON.stringify({
  header    : header,
  protected : b64protected,
  payload   : b64payload,
  signature : b64signature
});

うーん…なかなかややこしいですね。

これまでも何度か出てきましたが、
「Base64url Encoding」とは Base64 でエンコードした文字列から

  • '=' を削除
  • '+' を '-' に置換
  • '/' を '_' に置換

したものです。

本稿の説明の中で「データを POST する」としている箇所では、
実際には全て上記のように JWS + nonce の処理を行ったデータを POST しています。

謝辞・参考文献

「SSLなう!」は jsrsasign を使用しています。
素晴らしいライブラリを公開いただきありがとうございます。

その他参考にしたもの・利用したものなど

あとがき

フリーで取得できる「.ml」と Let's Encrypt の組み合わせで手軽に構築できました。
コードが汚いのはご愛嬌…

ほんとはドメイン名所有権確認方法として dns-01 (該当ドメイン名の TXT レコードに token を設定して確認) に対応させて
DNS Advent Calendar 2015 あたりに投稿しようかと思っていたのですが、
現時点で Let's Encrypt 側が dns-01 に対応してなかったので断念 (staging 環境では対応済みなのに…)。
とりあえず http-01 のみ対応しました。
Let's Encrypt の中の人としても dns-01 への対応は優先度「高」で対応中 とのことなので今後に期待ですね。

また今度の JANOG37 でも Let's Ecrypt と ACME についてのLT があるようなので注目です。
ライブストリーミングもあるようです。

最後に...

ご通信は SSL ですか?
(ごちうさロゴジェネレーターにて作成)

はい、結局これが言いたかっただけですね!!!
それではみなさん良い SSL ライフを & 良いお年を!

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away