From 5d505be5e10c19f9b472f30f9a40a9d137cb0dd2 Mon Sep 17 00:00:00 2001 From: David Somers <126989+jalada@users.noreply.github.com> Date: Fri, 10 May 2019 11:43:10 +0100 Subject: [PATCH 1/8] First pass at migrating to v2 & saving registration details --- CHANGELOG.md | 15 +++ Gemfile | 2 +- Gemfile.lock | 4 +- README.md | 12 ++- lib/letsencrypt-rails-heroku/exceptions.rb | 12 ++- lib/letsencrypt-rails-heroku/letsencrypt.rb | 18 +++- lib/tasks/letsencrypt.rake | 113 ++++++++++++++------ 7 files changed, 131 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 266b5b5..596c75c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +# 2.0.0 (unreleased) + + - *BREAKING* You must indicate your acceptance of Let's Encrypt's terms + and conditions by setting the `ACME_TERMS_AGREED` configuration variable. + - *BREAKING* Removed `ACME_ENDPOINT` environment variable reference. We never + documented that we support alternative endpoints, and we never tested it, + and the gem is called *letsencrypt*-rails-heroku, so let's not pretend. + Please get in touch if you were using this configuration variable, we'd + like to hear from you! Psst; you can still set `acme_directory` when + configuring the gem in an initializer. + - Use version 2 of the ACME API, paving the way for DNS validation. + - Save private key & key ID variables after registering with Let's Encrypt. + This will create two new permanent environment variables, `ACME_PRIVATE_KEY` + and `ACME_KEY_ID`. + # 1.2.1 - Update `rack` and `nokogiri` dependencies due to reported vulnerabilities diff --git a/Gemfile b/Gemfile index cc72379..f7f9c1f 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,6 @@ source "https://rubygems.org" -gem 'acme-client', '~> 0.4.0' +gem 'acme-client', '~> 2.0' gem 'platform-api', '~> 2.2' group :development do diff --git a/Gemfile.lock b/Gemfile.lock index ed1e19a..88bec19 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ GEM remote: https://rubygems.org/ specs: - acme-client (0.4.1) + acme-client (2.0.3) faraday (~> 0.9, >= 0.9.1) activesupport (5.0.0) concurrent-ruby (~> 1.0, >= 1.0.2) @@ -86,7 +86,7 @@ PLATFORMS ruby DEPENDENCIES - acme-client (~> 0.4.0) + acme-client (~> 2.0) bundler (~> 1.0) juwelier (~> 2.1.0) platform-api (~> 2.2) diff --git a/README.md b/README.md index e98e52b..275d511 100644 --- a/README.md +++ b/README.md @@ -66,12 +66,14 @@ which you should set. * `ACME_DOMAIN`: Comma separated list of domains for which you want certificates, e.g. `example.com,www.example.com`. Your Heroku app should be - configured to answer to all these domains, because LetsEncrypt will make a + configured to answer to all these domains, because Let's Encrypt will make a request to verify ownership. If you leave this blank, the gem will try and use the Heroku API to get a list of configured domains for your app, and verify all of them. * `ACME_EMAIL`: Your email address, should be valid. + * `ACME_TERMS_AGREED`: Existence of this environment variable represents your + agreement to [Let's Encrypt's terms of service](https://letsencrypt.org/repository/). * `HEROKU_TOKEN`: An API token for this app. See below * `HEROKU_APP`: Name of Heroku app e.g. bottomless-cavern-7173 * `SSL_TYPE`: Optional: One of `sni` or `endpoint`, defaults to `sni`. @@ -84,6 +86,14 @@ the challenge / validation process: * `ACME_CHALLENGE_FILENAME`: The path of the file LetsEncrypt will request. * `ACME_CHALLENGE_FILE_CONTENT`: The content of that challenge file. +It will also create two permanent environment variables after the first run: + + * `ACME_PRIVATE_KEY`: Private key used to create requests for certificates. + * `ACME_KEY_ID`: Key ID assigned to your private key by Let's Encrypt. + +If you remove these, a new account will be created and new environment +variables will be set. + ## Creating a Heroku token Use the `heroku-oauth` toolbelt plugin to generate an access token suitable diff --git a/lib/letsencrypt-rails-heroku/exceptions.rb b/lib/letsencrypt-rails-heroku/exceptions.rb index 09f68f4..8a5a3e2 100644 --- a/lib/letsencrypt-rails-heroku/exceptions.rb +++ b/lib/letsencrypt-rails-heroku/exceptions.rb @@ -1,12 +1,16 @@ module Letsencrypt module Error - # Exception raised when LetsEncrypt encounters an issue verifying the challenge. + # LetsEncrypt encountered an issue verifying the challenge. class VerificationError < StandardError; end - # Exception raised when challenge URL is not available. + # LetsEncrypt encountered an issue finalizing the order. + class FinalizationError < StandardError; end + # Challenge URL is not available. class ChallengeUrlError < StandardError; end - # Exception raised on timeout of challenge verification. + # Domain verification took longer than we'd like. class VerificationTimeoutError < StandardError; end - # Exception raised when an error occurs adding the certificate to Heroku. + # Order finalization took longer than we'd like. + class FinalizationTimeoutError < StandardError; end + # Error adding the certificate to Heroku. class HerokuCertificateError < StandardError; end end end diff --git a/lib/letsencrypt-rails-heroku/letsencrypt.rb b/lib/letsencrypt-rails-heroku/letsencrypt.rb index 4fa916a..98e88a2 100644 --- a/lib/letsencrypt-rails-heroku/letsencrypt.rb +++ b/lib/letsencrypt-rails-heroku/letsencrypt.rb @@ -14,26 +14,36 @@ def self.challenge_configured? configuration.acme_challenge_file_content end + def self.registered? + configuration.acme_private_key && configuration.acme_key_id + end + class Configuration attr_accessor :heroku_token, :heroku_app, :acme_email, :acme_domain, - :acme_endpoint, :ssl_type + :acme_directory, :ssl_type, :acme_terms_agreed # Not settable by user; part of the gem's behaviour. - attr_reader :acme_challenge_filename, :acme_challenge_file_content + attr_reader :acme_challenge_filename, :acme_challenge_file_content, + :acme_private_key, :acme_key_id def initialize @heroku_token = ENV["HEROKU_TOKEN"] @heroku_app = ENV["HEROKU_APP"] @acme_email = ENV["ACME_EMAIL"] @acme_domain = ENV["ACME_DOMAIN"] - @acme_endpoint = ENV["ACME_ENDPOINT"] || 'https://acme-v01.api.letsencrypt.org/' + @acme_directory = 'https://acme-v02.api.letsencrypt.org/directory' + @acme_terms_agreed = ENV["ACME_TERMS_AGREED"] @ssl_type = ENV["SSL_TYPE"] || 'sni' + @acme_challenge_filename = ENV["ACME_CHALLENGE_FILENAME"] @acme_challenge_file_content = ENV["ACME_CHALLENGE_FILE_CONTENT"] + + @acme_private_key = ENV["ACME_PRIVATE_KEY"] + @acme_key_id = ENV["ACME_KEY_ID"] end def valid? - heroku_token && heroku_app && acme_email + heroku_token && heroku_app && acme_email && acme_terms_agreed end end end diff --git a/lib/tasks/letsencrypt.rake b/lib/tasks/letsencrypt.rake index 56db067..c2edc6c 100644 --- a/lib/tasks/letsencrypt.rake +++ b/lib/tasks/letsencrypt.rake @@ -14,18 +14,41 @@ namespace :letsencrypt do heroku = PlatformAPI.connect_oauth Letsencrypt.configuration.heroku_token heroku_app = Letsencrypt.configuration.heroku_app - # Create a private key - print "Creating account key..." - private_key = OpenSSL::PKey::RSA.new(4096) - puts "Done!" + if Letsencrypt.registered? + puts "Using existing registration details" + private_key = OpenSSL::PKey::RSA.new(Letsencrypt.configuration.acme_key) + key_id = Letsencrypt.configuration.acme_key_id + else + # Create a private key + print "Creating account key..." + private_key = OpenSSL::PKey::RSA.new(4096) + puts "Done!" - client = Acme::Client.new(private_key: private_key, endpoint: Letsencrypt.configuration.acme_endpoint, connection_options: { request: { open_timeout: 5, timeout: 5 } }) + client = Acme::Client.new(private_key: private_key, + directory: Letsencrypt.configuration.acme_directory, + connection_options: { + request: { + open_timeout: 5, + timeout: 5 + } + }) - print "Registering with LetsEncrypt..." - registration = client.register(contact: "mailto:#{Letsencrypt.configuration.acme_email}") + print "Registering with LetsEncrypt..." + account = client.new_account(contact: "mailto:#{Letsencrypt.configuration.acme_email}", + terms_of_service_agreed: true) - registration.agree_terms - puts "Done!" + key_id = account.kid + puts "Done!" + print "Saving account details as configuration variables..." + heroku.config_var.update(heroku_app, + 'ACME_PRIVATE_KEY' => private_key.to_pem, + 'ACME_KEY_ID' => account.kid) + puts "Done!" + end + + client = Acme::Client.new(private_key: private_key, + directory: Letsencrypt.configuration.acme_directory, + kid: key_id) domains = [] if Letsencrypt.configuration.acme_domain @@ -36,11 +59,12 @@ namespace :letsencrypt do puts "Using #{domains.length} configured Heroku domain(s) for this app..." end - domains.each do |domain| + order = client.new_order(identifiers: domains) + + order.authorizations.each do |authorization| puts "Performing verification for #{domain}:" - authorization = client.authorize(domain: domain) - challenge = authorization.http01 + challenge = authorization.http print "Setting config vars on Heroku..." heroku.config_var.update(heroku_app, { @@ -78,24 +102,23 @@ namespace :letsencrypt do print "Giving LetsEncrypt some time to verify..." # Once you are ready to serve the confirmation request you can proceed. - challenge.request_verification # => true - challenge.verify_status # => 'pending' + challenge.request_verification start_time = Time.now - - while challenge.verify_status == 'pending' + while challenge.status == 'pending' if Time.now - start_time >= 30 failure_message = "Failed - timed out waiting for challenge verification." raise Letsencrypt::Error::VerificationTimeoutError, failure_message end - sleep(3) + sleep(2) + challenge.reload end puts "Done!" - unless challenge.verify_status == 'valid' + unless challenge.status == 'valid' puts "Problem verifying challenge." - failure_message = "Status: #{challenge.verify_status}, Error: #{challenge.error}" + failure_message = "Status: #{challenge.status}, Error: #{challenge.error}" raise Letsencrypt::Error::VerificationError, failure_message end @@ -110,10 +133,33 @@ namespace :letsencrypt do }) # Create CSR - csr = Acme::Client::CertificateRequest.new(names: domains) + csr_private_key = OpenSSL::PKey::RSA.new 4096 + csr = Acme::Client::CertificateRequest.new(names: domains, + private_key: csr_private_key) + print "Asking LetsEncrypt to finalize our certificate order..." # Get certificate - certificate = client.new_certificate(csr) # => # + order.finalize(csr: csr) + + # Wait for order to process + start_time = Time.now + while order.status == 'processing' + if Time.now - start_time >= 30 + failure_message = "Failed - timed out waiting for order finalization" + raise Letsencrypt::Error::FinalizationTimeoutError, failure_message + end + sleep(2) + order.reload + end + + puts "Done!" + + unless order.status == 'ready' + failure_message = "Problem finalizing order - status: #{order.status}" + raise Letsencrypt::Error::FinalizationError, failure_message + end + + certificate = order.certificate # => PEM-formatted certificate # Send certificates to Heroku via API @@ -124,23 +170,24 @@ namespace :letsencrypt do heroku.ssl_endpoint end - # First check for existing certificates: - certificates = endpoint.list(heroku_app) + certificate_info = { + certificate_chain: certificate, + private_key: csr_private_key.to_pem + } + + # Fetch existing certificate from Heroku (if any). We just use the first + # one; if someone has more than one, they're probably not actually using + # this gem. Could also be an error? + existing_certificate = endpoint.list(heroku_app)[0] begin - if certificates.any? - print "Updating existing certificate #{certificates[0]['name']}..." - endpoint.update(heroku_app, certificates[0]['name'], { - certificate_chain: certificate.fullchain_to_pem, - private_key: certificate.request.private_key.to_pem - }) + if existing_certificate + print "Updating existing certificate #{existing_certificate['name']}..." + endpoint.update(heroku_app, existing_certificates['name'], certificate_info) puts "Done!" else print "Adding new certificate..." - endpoint.create(heroku_app, { - certificate_chain: certificate.fullchain_to_pem, - private_key: certificate.request.private_key.to_pem - }) + endpoint.create(heroku_app, certificate_info) puts "Done!" end rescue Excon::Error::UnprocessableEntity => e From 579a08cc5d948c9493826bfdf940c50e8ae1fa73 Mon Sep 17 00:00:00 2001 From: David Somers <126989+jalada@users.noreply.github.com> Date: Fri, 10 May 2019 11:58:31 +0100 Subject: [PATCH 2/8] Update gemspec for new dependency, tweaks to README --- README.md | 17 ++++++++++------- VERSION | 2 +- letsencrypt-rails-heroku.gemspec | 12 ++++++------ lib/tasks/letsencrypt.rake | 2 +- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 275d511..7bdaf32 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,16 @@ end ## Configuring -By default the gem will try to use the following set of configuration variables, -which you should set. +By default the gem will try to use the following set of configuration +variables. You must set: + + * `ACME_EMAIL`: Your email address, should be valid. + * `ACME_TERMS_AGREED`: Existence of this environment variable represents your + agreement to [Let's Encrypt's terms of service](https://letsencrypt.org/repository/). + * `HEROKU_TOKEN`: An API token for this app. See below + * `HEROKU_APP`: Name of Heroku app e.g. bottomless-cavern-7173 + +You can also set: * `ACME_DOMAIN`: Comma separated list of domains for which you want certificates, e.g. `example.com,www.example.com`. Your Heroku app should be @@ -71,11 +79,6 @@ which you should set. If you leave this blank, the gem will try and use the Heroku API to get a list of configured domains for your app, and verify all of them. - * `ACME_EMAIL`: Your email address, should be valid. - * `ACME_TERMS_AGREED`: Existence of this environment variable represents your - agreement to [Let's Encrypt's terms of service](https://letsencrypt.org/repository/). - * `HEROKU_TOKEN`: An API token for this app. See below - * `HEROKU_APP`: Name of Heroku app e.g. bottomless-cavern-7173 * `SSL_TYPE`: Optional: One of `sni` or `endpoint`, defaults to `sni`. `endpoint` requires your app to have an [SSL endpoint addon](https://elements.heroku.com/addons/ssl) configured. diff --git a/VERSION b/VERSION index cb174d5..227cea2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.1 \ No newline at end of file +2.0.0 diff --git a/letsencrypt-rails-heroku.gemspec b/letsencrypt-rails-heroku.gemspec index d29b35e..09fd277 100644 --- a/letsencrypt-rails-heroku.gemspec +++ b/letsencrypt-rails-heroku.gemspec @@ -2,16 +2,16 @@ # DO NOT EDIT THIS FILE DIRECTLY # Instead, edit Juwelier::Tasks in Rakefile, and run 'rake gemspec' # -*- encoding: utf-8 -*- -# stub: letsencrypt-rails-heroku 1.2.1 ruby lib +# stub: letsencrypt-rails-heroku 2.0.0 ruby lib Gem::Specification.new do |s| s.name = "letsencrypt-rails-heroku".freeze - s.version = "1.2.1" + s.version = "2.0.0" s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version= s.require_paths = ["lib".freeze] s.authors = ["Pixie Labs".freeze, "David Somers".freeze, "Abigail McPhillips".freeze] - s.date = "2019-04-12" + s.date = "2019-05-10" s.description = "This gem automatically handles creation, renewal, and applying SSL certificates from LetsEncrypt to your Heroku account.".freeze s.email = "team@pixielabs.io".freeze s.extra_rdoc_files = [ @@ -44,7 +44,7 @@ Gem::Specification.new do |s| s.specification_version = 4 if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then - s.add_runtime_dependency(%q.freeze, ["~> 0.4.0"]) + s.add_runtime_dependency(%q.freeze, ["~> 2.0"]) s.add_runtime_dependency(%q.freeze, ["~> 2.2"]) s.add_development_dependency(%q.freeze, [">= 0"]) s.add_development_dependency(%q.freeze, ["~> 3.12"]) @@ -52,7 +52,7 @@ Gem::Specification.new do |s| s.add_development_dependency(%q.freeze, ["~> 2.1.0"]) s.add_development_dependency(%q.freeze, [">= 0"]) else - s.add_dependency(%q.freeze, ["~> 0.4.0"]) + s.add_dependency(%q.freeze, ["~> 2.0"]) s.add_dependency(%q.freeze, ["~> 2.2"]) s.add_dependency(%q.freeze, [">= 0"]) s.add_dependency(%q.freeze, ["~> 3.12"]) @@ -61,7 +61,7 @@ Gem::Specification.new do |s| s.add_dependency(%q.freeze, [">= 0"]) end else - s.add_dependency(%q.freeze, ["~> 0.4.0"]) + s.add_dependency(%q.freeze, ["~> 2.0"]) s.add_dependency(%q.freeze, ["~> 2.2"]) s.add_dependency(%q.freeze, [">= 0"]) s.add_dependency(%q.freeze, ["~> 3.12"]) diff --git a/lib/tasks/letsencrypt.rake b/lib/tasks/letsencrypt.rake index c2edc6c..67fa69f 100644 --- a/lib/tasks/letsencrypt.rake +++ b/lib/tasks/letsencrypt.rake @@ -8,7 +8,7 @@ namespace :letsencrypt do desc 'Renew your LetsEncrypt certificate' task :renew do # Check configuration looks OK - abort "letsencrypt-rails-heroku is configured incorrectly. Are you missing an environment variable or other configuration? You should have a heroku_token, heroku_app and acme_email configured either via a `Letsencrypt.configure` block in an initializer or as environment variables." unless Letsencrypt.configuration.valid? + abort "letsencrypt-rails-heroku is configured incorrectly. Are you missing an environment variable or other configuration? You should have heroku_token, heroku_app, acme_email and acme_terms_agreed configured either via a `Letsencrypt.configure` block in an initializer or as environment variables." unless Letsencrypt.configuration.valid? # Set up Heroku client heroku = PlatformAPI.connect_oauth Letsencrypt.configuration.heroku_token From 414efaacf6bb60db6a0cfa61bcbcb5f1d5f07417 Mon Sep 17 00:00:00 2001 From: David Somers <126989+jalada@users.noreply.github.com> Date: Fri, 10 May 2019 12:04:26 +0100 Subject: [PATCH 3/8] Typo --- lib/tasks/letsencrypt.rake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/tasks/letsencrypt.rake b/lib/tasks/letsencrypt.rake index 67fa69f..c685904 100644 --- a/lib/tasks/letsencrypt.rake +++ b/lib/tasks/letsencrypt.rake @@ -62,7 +62,7 @@ namespace :letsencrypt do order = client.new_order(identifiers: domains) order.authorizations.each do |authorization| - puts "Performing verification for #{domain}:" + puts "Performing verification for #{authorization.domain}:" challenge = authorization.http From 0216a9cb6587df62767bd412363f20cd62c61ef7 Mon Sep 17 00:00:00 2001 From: David Somers <126989+jalada@users.noreply.github.com> Date: Fri, 10 May 2019 12:07:47 +0100 Subject: [PATCH 4/8] Typo in config var --- lib/tasks/letsencrypt.rake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/tasks/letsencrypt.rake b/lib/tasks/letsencrypt.rake index c685904..895c129 100644 --- a/lib/tasks/letsencrypt.rake +++ b/lib/tasks/letsencrypt.rake @@ -16,7 +16,7 @@ namespace :letsencrypt do if Letsencrypt.registered? puts "Using existing registration details" - private_key = OpenSSL::PKey::RSA.new(Letsencrypt.configuration.acme_key) + private_key = OpenSSL::PKey::RSA.new(Letsencrypt.configuration.acme_private_key) key_id = Letsencrypt.configuration.acme_key_id else # Create a private key From 9f62302383f693d52b57f7210a4a88a5a8f01d01 Mon Sep 17 00:00:00 2001 From: David Somers <126989+jalada@users.noreply.github.com> Date: Fri, 10 May 2019 12:10:46 +0100 Subject: [PATCH 5/8] New method name --- lib/tasks/letsencrypt.rake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/tasks/letsencrypt.rake b/lib/tasks/letsencrypt.rake index 895c129..abd29e6 100644 --- a/lib/tasks/letsencrypt.rake +++ b/lib/tasks/letsencrypt.rake @@ -102,7 +102,7 @@ namespace :letsencrypt do print "Giving LetsEncrypt some time to verify..." # Once you are ready to serve the confirmation request you can proceed. - challenge.request_verification + challenge.request_validation start_time = Time.now while challenge.status == 'pending' From 688f795cf80dfe42f6b061159225612f327b85e5 Mon Sep 17 00:00:00 2001 From: David Somers <126989+jalada@users.noreply.github.com> Date: Fri, 10 May 2019 12:14:39 +0100 Subject: [PATCH 6/8] valid, not ready --- lib/tasks/letsencrypt.rake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/tasks/letsencrypt.rake b/lib/tasks/letsencrypt.rake index abd29e6..cc0f401 100644 --- a/lib/tasks/letsencrypt.rake +++ b/lib/tasks/letsencrypt.rake @@ -154,7 +154,7 @@ namespace :letsencrypt do puts "Done!" - unless order.status == 'ready' + unless order.status == 'valid' failure_message = "Problem finalizing order - status: #{order.status}" raise Letsencrypt::Error::FinalizationError, failure_message end From 8097b032b1fec2fd4d257939c490f966290f66b3 Mon Sep 17 00:00:00 2001 From: David Somers <126989+jalada@users.noreply.github.com> Date: Fri, 10 May 2019 12:28:36 +0100 Subject: [PATCH 7/8] Update CHANGELOG to include thanks --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 596c75c..a1cd5eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # 2.0.0 (unreleased) +Thanks to [@mashedkeyboard](https://github.com/mashedkeyboard) for their +work on ACME v2, saving registration, and DNS-based validation. + - *BREAKING* You must indicate your acceptance of Let's Encrypt's terms and conditions by setting the `ACME_TERMS_AGREED` configuration variable. - *BREAKING* Removed `ACME_ENDPOINT` environment variable reference. We never From f91647eef57ae928b6426c4a84d418da5f4076cc Mon Sep 17 00:00:00 2001 From: David Somers <126989+jalada@users.noreply.github.com> Date: Fri, 17 May 2019 10:35:15 +0100 Subject: [PATCH 8/8] Update letsencrypt.rake Added comment --- lib/tasks/letsencrypt.rake | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/tasks/letsencrypt.rake b/lib/tasks/letsencrypt.rake index cc0f401..da77857 100644 --- a/lib/tasks/letsencrypt.rake +++ b/lib/tasks/letsencrypt.rake @@ -46,6 +46,7 @@ namespace :letsencrypt do puts "Done!" end + # Make a new Acme::Client with whichever private_key & key_id we ended up with. client = Acme::Client.new(private_key: private_key, directory: Letsencrypt.configuration.acme_directory, kid: key_id)