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

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は適当に読み替えで。

追記:
v2では、 identifiersとして渡したドメインはLowercaseで処理されるようになりました。
あとでつくるauthorizationで、domain属性の中身が ACME2.example.com => acmev2.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するかもしれない
  • ドメインが内部的に全部lowercaseで扱われるようになった。

付録: 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"
Why not register and get more from Qiita?
  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
No 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
ユーザーは見つかりませんでした