Ruby
letsencrypt

RubyでLet's EncryptのスクリプトACMEv2

前回ACMEv1でやったのはこちら。 RubyでLet's Encryptのスクリプト - Qiita

Let's EncryptでACMEv2が使えるようになり、rubyのクライアントもv2対応してしばらく経ちました。
一応v1で困ってはいなかったのでちゃんと見ていなかったが、いつまでもv1で置いておくのも気持ちが悪いのでv2の仕様を確認することにした。

v1との相違点なんかを確認しながら同じことをやってみよう。

あくまでRubyのクライアントでv1=>v2を使う際の変更点などであることを事前にご了承で。

以下、Let's EncryptはLEと呼称します。ほか、本記事内でのv2は文脈によって次のうちのいずれかを指しますので、少々読みにくいかもしれませんが流れで判断してください。

  • 仕様としてのACMEv2
  • LEのACMEv2仕様API
  • ruby acme-clientのv2

環境

  • IDCFクラウドにDebian9のVM
    • aptでRuby, acme-client v2.0.1
  • WebサーバとしてNginx
    • rootは /var/www/html;

pryで実行していきます。

ステップ 秘密鍵

ここは同じですね。

> require 'openssl'
=> true
> private_key = OpenSSL::PKey::RSA.new(2048)
=> #<OpenSSL::PKey::RSA:0x00007fc6491e3540>

ステップ ACMEv2のディレクトリ (※エンドポイント指定からちょっと変更)

v2用から、directoryのパス込みで指定するようになってます。
引数のキーもdirectoryなので、変数名もdirectoryにしちゃおう。

directory = 'https://acme-staging-v02.api.letsencrypt.org/directory'

例によってACMEv2に対応しているAPIならなんでもよいです。

ステップ ACMEクライアントの初期化

手順はあまり変わらず、directory指定となった。
オブジェクトの中身は結構変わったね。秘密鍵がjwkの一部として使われている。

> require 'acme-client'
=> true
> Acme::Client::VERSION
=> "2.0.1"

> client = Acme::Client.new(private_key: private_key, directory: directory)
=> #<Acme::Client:0x00007fc64a30b8e0
 @connection_options={},
 @directory=
  #<Acme::Client::Resources::Directory:0x00007fc64a30b250
   @connection_options={},
   @url=#<URI::HTTPS https://acme-staging-v02.api.letsencrypt.org/directory>>,
 @jwk=#<Acme::Client::JWK::RSA:0x00007fc64a30b7f0 @private_key=#<OpenSSL::PKey::RSA:0x00007fc6491e3540>>,
 @kid=nil,
 @nonces=[]>

また、すでに鍵を登録済み(≒アカウントがある)であれば、後述のkidを使ってクライアントを作成することができるようだ。

ステップ CAへの公開鍵登録(初回)

terms_of_service_agreedオプションがついて、更に形骸化簡略化した。
のはまあいいとして、v2では登録した秘密鍵に対応するkidという識別情報がもらえるようになった。

> account = client.new_account(contact: 'mailto:sawanoboriyu+acme@example.com', terms_of_service_agreed: true)
=> #<Acme::Client::Resources::Account:0x00007fc64a2faea0
 @client=
  #<Acme::Client:0x00007fc64a30b8e0
   @connection_options={},
   @connections=
    ...
   @directory=
    #<Acme::Client::Resources::Directory:0x00007fc64a30b250
     @connection_options={},
     @directory=
      {:meta=>
        {"caaIdentities"=>["letsencrypt.org"],
         "termsOfService"=>"https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf",
         "website"=>"https://letsencrypt.org/docs/staging-environment/"},
       :new_nonce=>#<URI::HTTPS https://acme-staging-v02.api.letsencrypt.org/acme/new-nonce>,
       :new_account=>#<URI::HTTPS https://acme-staging-v02.api.letsencrypt.org/acme/new-acct>,
       :new_order=>#<URI::HTTPS https://acme-staging-v02.api.letsencrypt.org/acme/new-order>,
       :revoke_certificate=>#<URI::HTTPS https://acme-staging-v02.api.letsencrypt.org/acme/revoke-cert>,
       :key_change=>#<URI::HTTPS https://acme-staging-v02.api.letsencrypt.org/acme/key-change>},
     @url=#<URI::HTTPS https://acme-staging-v02.api.letsencrypt.org/directory>>,
   @jwk=#<Acme::Client::JWK::RSA:0x00007fc64a30b7f0 @private_key=#<OpenSSL::PKey::RSA:0x00007fc6491e3540>>,
   @kid="https://acme-staging-v02.api.letsencrypt.org/acme/acct/xxxxxxxx",
   @nonces=["uX19iMR4YlWAdYiu-KMzPdlYDFzpoAB-4-ikAvJPGQg"]>,
 @contact=["mailto:sawanoboriyu+acme@example.com"],
 @status="valid",
 @term_of_service=nil,
 @url="https://acme-staging-v02.api.letsencrypt.org/acme/acct/xxxxxxxx">

上記のレスポンスでkidが.../acme/acct/xxxxxxxx といった感じで含まれています。

ちなみにmailto:では@example.com,@example.netといったドメインはブラックリストで却下されます。(これもLEのACMEv2 APIからだと思う)

invalid contact domain. Contact emails @example.com are forbidden

なにかしら有効なドメインをつかいましょう。こちらは秘密鍵と違って重複しても問題ないはず。

kid?

登録済みのアカウントを使い回すには、kidを保存しておかないと駄目なの?というと、そうでもないです。

> account.kid
=> "https://acme-staging-v02.api.letsencrypt.org/acme/acct/xxxxxxxx"

クライアント初期化時にオプションとして渡すことができて、いちおうなんとなくスムーズな感じですすめることができます。

> kid = account.kid
=> "https://acme-staging-v02.api.letsencrypt.org/acme/acct/xxxxxxxx"

# kidを指定したクライアント作成
> client = Acme::Client.new(private_key: private_key, directory: directory, kid: kid)
=> #<Acme::Client:0x00007fc64aa9a840
 @connection_options={},
 @directory=#<Acme::Client::Resources::Directory:0x00007fc64aa9a5e8 @connection_options={}, @url=#<URI::HTTPS https://acme-staging-v02.api.letsencrypt.org/directory>>,
 @jwk=#<Acme::Client::JWK::RSA:0x00007fc64aa9a7c8 @private_key=#<OpenSSL::PKey::RSA:0x00007fc6491e3540>>,
 @kid="https://acme-staging-v02.api.letsencrypt.org/acme/acct/xxxxxxxx",
 @nonces=[]>

ただ、すでに登録済みの秘密鍵を使ったクライアントは、kidを省略してもLE側で勝手に突き合わせてくれます。

# 初期化直後は kid = nil
> client = Acme::Client.new(private_key: private_key, directory: directory)
=> #<Acme::Client:0x00007fc6491e9260
 @connection_options={},
 @directory=#<Acme::Client::Resources::Directory:0x00007fc6491e9008 @connection_options={}, @url=#<URI::HTTPS https://acme-staging-v02.api.letsencrypt.org/directory>>,
 @jwk=#<Acme::Client::JWK::RSA:0x00007fc6491e91e8 @private_key=#<OpenSSL::PKey::RSA:0x00007fc6491e3540>>,
 @kid=nil,
 @nonces=[]>

# でも取りに行けば、値があることが確認できる。
> client.kid
# 取得にはすこし時間がかかる
=> "https://acme-staging-v02.api.letsencrypt.org/acme/acct/xxxxxxxx"

この辺はkidを使っても良いし、v1のときのように気にせず先にすすめてよいように思います。

kidの値が間違っている場合でも、クライアントは作成可能。このあとのnew_orderでハネられます。

ステップ registration(v1) .... なくなった。

アカウント作成時に約款同意を含めたので不要と。

ステップ ドメインのチャレンジを申請する

v1ではclientからauthorizationを作っていたが、v2では一旦Acme::Client::Resources::Orderのインスタンスを作るようになった。
※このあとのexample.comは適当に読み替えで。

> order = client.new_order(identifiers: ['acmev2.example.com'])
=> #<Acme::Client::Resources::Order:0x00007fc64a506a50
 @authorization_urls=["https://acme-staging-v02.api.letsencrypt.org/acme/authz/8Kd11K9K7_sw4MyyEW3q5137Vphs0OLWHToQrlaax2A"],
 @certificate_url=nil,
 @client=
  #<Acme::Client:0x00007fc6491e9260
   @connection_options={},
   @connections=
    ...
   @directory=
    #<Acme::Client::Resources::Directory:0x00007fc6491e9008
     @connection_options={},
     @directory=
      ...
     @url=#<URI::HTTPS https://acme-staging-v02.api.letsencrypt.org/directory>>,
   @jwk=#<Acme::Client::JWK::RSA:0x00007fc6491e91e8 @private_key=#<OpenSSL::PKey::RSA:0x00007fc6491e3540>>,
   @kid="https://acme-staging-v02.api.letsencrypt.org/acme/acct/xxxxxxxx",
   @nonces=["yt3TCMgt67czokGFRzoQAr5JSWvMq-APFc5Ygak6JGg"]>,
 @expires="2018-09-04T05:38:09.929580426Z",
 @finalize_url="https://acme-staging-v02.api.letsencrypt.org/acme/finalize/xxxxxxxx/6557233",
 @identifiers=[{"type"=>"dns", "value"=>"example.com"}],
 @status="pending",
 @url="https://acme-staging-v02.api.letsencrypt.org/acme/order/xxxxxxxx/6557233">

なんでOrderを1つかませるようにしたのかなと、new_orderの実装をみてみる。
なるほどnot_before, not_afterなどが使えるようになったから、一枚かますようになったのかな。

# Owner: Acme::Client
# Visibility: public
# Number of lines: 16

def new_order(identifiers:, not_before: nil, not_after: nil)
  payload = {}
  payload['identifiers'] = if identifiers.is_a?(Hash)
    identifiers
  else
    Array(identifiers).map do |identifier|
      { type: 'dns', value: identifier }
    end
  end
  payload['notBefore'] = not_before if not_before
  payload['notAfter'] = not_after if not_after

  response = post(endpoint_for(:new_order), payload: payload)
  arguments = attributes_from_order_response(response)
  Acme::Client::Resources::Order.new(self, **arguments)
end

authorizationはorder.authorizations.firstとして

authorizations自体は要素が1つの配列だけど、他の方式とかへの対応のため拡張可能な仕様なんだろう。

> authorization = order.authorizations.first
=> #<Acme::Client::Resources::Authorization:0x00007fc64a9e28d0
 @challenges=
  [{"type"=>"tls-alpn-01",
    "status"=>"pending",
    "url"=>"https://acme-staging-v02.api.letsencrypt.org/acme/challenge/8Kd11K9K7_sw4MyyEW3q5137Vphs0OLWHToQrlaax2A/164428936",
    "token"=>"8xTyIIL4sip6nJlI_7gg9AMp_3uFByQXHoqIRP0gbqQ"},
   {"type"=>"http-01",
    "status"=>"pending",
    "url"=>"https://acme-staging-v02.api.letsencrypt.org/acme/challenge/8Kd11K9K7_sw4MyyEW3q5137Vphs0OLWHToQrlaax2A/164428937",
    "token"=>"_Nu_4S3OuYkaMG-Kq5DHrX05zp9tlx-jubnMg0-k1v4"},
   {"type"=>"dns-01",
    "status"=>"pending",
    "url"=>"https://acme-staging-v02.api.letsencrypt.org/acme/challenge/8Kd11K9K7_sw4MyyEW3q5137Vphs0OLWHToQrlaax2A/164428938",
    "token"=>"yxJQO-KqzLwG70wnV5gs6PCF7v0JrGJdhvEa5kRtzNk"}],
 @client=
   ...
 @domain="example.com",
 @expires="2018-09-04T05:38:09Z",
 @identifier={"type"=>"dns", "value"=>"acmev2.example.com"},
 @status="pending",
 @url="https://acme-staging-v02.api.letsencrypt.org/acme/authz/8Kd11K9K7_sw4MyyEW3q5137Vphs0OLWHToQrlaax2A",
 @wildcard=nil>

tls-alpn-01, http-01, dns-01 の3方式が

tls-alpn-01(The TLS with Application Level Protocol Negotiation) については https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-01 で。

ステップ http-01タイプでチャレンジする

とりあえずv1の記事と同じようにhttp-01で。

> challenge = authorization.http
=> #<Acme::Client::Resources::Challenges::HTTP01:0x00007fc649a813c8
 @client=
  #<Acme::Client:0x00007fc6491e9260
   @connection_options={},
   @connections=
    ...
   @directory=
    ...
   @jwk=#<Acme::Client::JWK::RSA:0x00007fc6491e91e8 @private_key=#<OpenSSL::PKey::RSA:0x00007fc6491e3540>>,
   @kid="https://acme-staging-v02.api.letsencrypt.org/acme/acct/6820825",
   @nonces=[]>,
 @error=nil,
 @status="pending",
 @token="_Nu_4S3OuYkaMG-Kq5DHrX05zp9tlx-jubnMg0-k1v4",
 @url="https://acme-staging-v02.api.letsencrypt.org/acme/challenge/8Kd11K9K7_sw4MyyEW3q5137Vphs0OLWHToQrlaax2A/164428937">

challengeの中身を確認しよう。ここはv1と全く同じですね。

> challenge.content_type
=> "text/plain"

> challenge.file_content
=> "MVOFaIaalrqjB_570ZnCztuJgQcW9zxQxIu2YcNllU0.84USDpgw-Xi9j6lV8ucRAwUOVv3-Aos2bQmb-cgACkU"

> challenge.filename
=> ".well-known/acme-challenge/MVOFaIaalrqjB_570ZnCztuJgQcW9zxQxIu2YcNllU0"

> challenge.token
=> "MVOFaIaalrqjB_570ZnCztuJgQcW9zxQxIu2YcNllU0"

チャレンジのレスポンスを設置しよう

ここもv1同様に。

> www_root = '/var/www/html'
=> "/var/www/html"

> FileUtils.mkdir_p( File.join( www_root , File.dirname( challenge.filename ) ) )
=> ["/var/www/html/.well-known/acme-challenge"]

> File.write( File.join( www_root, challenge.filename), challenge.file_content )
=> 87

ここでContent-Type: text/plainになるようにnginx.confの修正も忘れないように。 前回記事参照

チャレンジ対応OKをLEに通知

request_validationはv1と一緒、でも状態の更新はchallenge.reloadに変更となってる。

> challenge.status
=> "pending"

> challenge.request_validation
=> true


> challenge.status
=> "pending"

> challenge.reload
=> true
> challenge.status
=> "valid"

HTTPのリクエストログにはこんな感じで来ています。

GET /.well-known/acme-challenge/MVOFaIaalrqjB_570ZnCztuJgQcW9zxQxIu2YcNllU0 HTTP/1.1" 200 87 "-" "Mozilla/5.0 (compatible; Let's Encrypt validation server; +https://www.letsencrypt.org)"

challengeがinvalidの場合、今回は challenge.error にレスポンスがはいっています。
こんな感じで
=> {"type"=>"urn:ietf:params:acme:error:unauthorized", "detail"=>"No TXT record found at _acme-challenge.test.example.com", "status"=>403}

ステップ CSRを作成する

Acme::Client::CertificateRequestが自動で秘密鍵を作ってくれるのはv1同様だが、単体のときでもcommon_nameとして指定するようになった。。のかな?

> csr = Acme::Client::CertificateRequest.new(subject: { common_name: 'acmev2.example.com' })
=> #<Acme::Client::CertificateRequest:0x00007fc649ae51e8
 @common_name="example.com",
 @digest=#<OpenSSL::Digest::SHA256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855>,
 @names=["example.com"],
 @private_key=#<OpenSSL::PKey::RSA:0x00007fc649ae5148>,
 @subject={"CN"=>"example.com"}>

ステップ CSRから証明書発行を申請する (※非同期になった)

v1との違いとして、ここでorderが再登場。

> order.finalize(csr: csr)
=> true

# 出来上がるまでは 'processing'
> order.status
=> "valid"

ステップ 作られた証明書を確認しよう

acme-client v2ではorderにはいっている。

> order.certificate
=> "-----BEGIN CERTIFICATE-----\nMIIF9TCCBN2gAwIBAgITAPqK0JUd ....

[初版から訂正] このorder.certificateは新規証明書+中間証明書が含まれていて、v1でのfull_chainにあたる内容がはいっています。

先頭が今回作成した証明書、それ以降が中間証明書。

certs = order.certificate.split("\n\n")

> OpenSSL::X509::Certificate.new(certs[0])
=> #<OpenSSL::X509::Certificate
 subject=#<OpenSSL::X509::Name CN=acmev2.example.com>,
 issuer=#<OpenSSL::X509::Name CN=Fake LE Intermediate X1>,
 serial=#<OpenSSL::BN 21825307703253062656376784444942033656726194>,
 not_before=2018-08-28 05:23:50 UTC,
 not_after=2018-11-26 05:23:50 UTC>


> OpenSSL::X509::Certificate.new(certs[1])
=> #<OpenSSL::X509::Certificate
 subject=#<OpenSSL::X509::Name CN=Fake LE Intermediate X1>,
 issuer=#<OpenSSL::X509::Name CN=Fake LE Root X1>,
 serial=#<OpenSSL::BN 185931811205298764263482705882344673253>,
 not_before=2016-05-23 22:07:59 UTC,
 not_after=2036-05-23 22:07:59 UTC>

ペアの鍵はcsrから持ってくる必要があると。

> csr.private_key.to_pem
=> "-----BEGIN RSA PRIVATE KEY-----\nM

通して実行

ここまでのスクリプトをまとめてみるとこんな感じ、大筋はv1と変わらず。

registrationがいらない分すこしだけ短くなったのと、cert作成が非同期になったのでwaitが一箇所増えた。

acmev2.rb
require 'openssl'

private_key = OpenSSL::PKey::RSA.new(2048)
directory = 'https://acme-v02.api.letsencrypt.org/directory'

require 'acme-client'

client = Acme::Client.new(private_key: private_key, directory: directory)
account = client.new_account(contact: 'mailto:sawanoboriyu+acme@example.com', terms_of_service_agreed: true)
puts account.kid

order = client.new_order(identifiers: ['acmev2.example.com'])
authorization = order.authorizations.first

challenge = authorization.http

www_root = '/var/www/html'
FileUtils.mkdir_p( File.join( www_root , File.dirname( challenge.filename ) ) )
File.write( File.join( www_root, challenge.filename), challenge.file_content )

challenge.request_validation

while challenge.status == 'pending'
  print "."
  sleep 2
  challenge.reload
end
puts challenge.status
raise if challenge.status != 'valid'

csr = Acme::Client::CertificateRequest.new(subject: { common_name: 'acmev2.example.com' })
order.finalize(csr: csr)
sleep(1) while order.status == 'processing'

File.write("privkey.pem", csr.private_key.to_pem)
File.write("fullchain.pem", order.certificate)

certs = order.certificate.split("\n\n")

certs.each do |cert|
  new_cert = OpenSSL::X509::Certificate.new(cert)
  puts new_cert.subject.to_s
  puts new_cert.not_after
end

実行してみる。

$ bundle exec ruby ./acmev2.rb
https://acme-v02.api.letsencrypt.org/acme/acct/xxxxxxxx
.valid
/CN=acmev2.example.com/serialNumber=xxxxxxxxxxxxxxxx
2018-11-26 06:21:39 UTC
/CN=h2ppy h2cker fake CA
2021-03-21 02:47:52 UTC

Rubyのacme-client v1 -> v2 まとめ

あくまでRubyライブラリ視点なので、そのへん注意で。

  • ACMEプロトコルがv1からv2になった
    • (そもそもこれに追随した各種変更でもある)
  • registrationは要らなくなった
  • アカウント(≒秘密鍵)の使い回しはkidもついでに保存しておく選択肢が増えた
    • 正しいkidを使えば、チャレンジの段取りが通しですこしだけ早くなるかもしれない
  • 証明書申請に関する一連のやりとりはAcme::Client::Resources::Orderで取りまとめることになった
  • [訂正:そうでもなかった]キーペア、中間証明書の取り出しがやや面倒になってる
    • これは簡単なローカルの関数でも対応できるし、せっかくだからメソッドを生やすためにPRするかもしれない

付録: wildcardもやっとく?

orderのidentifiersに*.で始まるドメイン名を使えばワイルドカード証明書も作れます。というかACMEv2の目玉よね。
折角だからやっときましょうか。

> order = client.new_order(identifiers: ['*.test.opsrockin.com'])
=> #<Acme::Client::Resources::Order:0x00007fc649b47848
...

> authorization = order.authorizations.first
=> #<Acme::Client::Resources::Authorization:0x00007fc64aa71170
...

# 例によってHTTP01はなし
> authorization.http01
=> nil


> challenge = authorization.dns01
=> #<Acme::Client::Resources::Challenges::DNS01:0x00007fc64a4fc5c8


> challenge.record_type
=> "TXT"

> challenge.record_name
=> "_acme-challenge"

> challenge.record_content
=> "yo1HkEkMu8q8mebJ4Sr5CY2Gjp6gtoybHNp6WdVKQyM"

# DNSレコードを作ってくる

> challenge.request_validation
=> true
> challenge.reload
=> true
> challenge.status
=> "valid"


> csr = Acme::Client::CertificateRequest.new(subject: { common_name: '*.test.example.com' })
=> #<Acme::Client::CertificateRequest:0x00007fc649a921a0

> order.finalize(csr: csr)
=> true


# できた
> OpenSSL::X509::Certificate.new(order.certificate).subject.to_s
=> "/CN=*.test.example.com"