diff options
23 files changed, 998 insertions, 1 deletions
| diff --git a/leap_cli.gemspec b/leap_cli.gemspec index ca4518a..48c75ce 100644 --- a/leap_cli.gemspec +++ b/leap_cli.gemspec @@ -57,6 +57,10 @@ spec = Gem::Specification.new do |s|    # s.add_runtime_dependency('gpgme')    # << does not build on debian jessie, so now optional.                                           # also, there is a ruby-gpgme package anyway. +  # acme-client is vendored for now, we need pre-lease version +  # s.add_runtime_dependency('acme-client', '~> 0.4.2') +  s.add_runtime_dependency('faraday', '~> 0.9', '>= 0.9.1') # for acme-client +    # misc gems    s.add_runtime_dependency('ya2yaml', '~> 0.31')    # pure ruby yaml, so we can better control output. see https://github.com/afunai/ya2yaml    s.add_runtime_dependency('json_pure', '~> 1.8')   # pure ruby json, so we can better control output. diff --git a/lib/leap_cli/version.rb b/lib/leap_cli/version.rb index bb8bbaf..bb8b57c 100644 --- a/lib/leap_cli/version.rb +++ b/lib/leap_cli/version.rb @@ -7,7 +7,8 @@ module LeapCli      LOAD_PATHS = ['lib',        'vendor/certificate_authority/lib',        'vendor/rsync_command/lib', -      'vendor/base32/lib' +      'vendor/base32/lib', +      'vendor/acme-client/lib'      ]    end  end diff --git a/vendor/acme-client/Gemfile b/vendor/acme-client/Gemfile new file mode 100644 index 0000000..e0b10df --- /dev/null +++ b/vendor/acme-client/Gemfile @@ -0,0 +1,12 @@ +source 'https://rubygems.org' +gemspec + +group :development, :test do +  gem 'pry' +  gem 'rubocop', '0.36.0' +  gem 'ruby-prof', require: false + +  if Gem::Version.new(RUBY_VERSION) <= Gem::Version.new('2.2.2') +    gem 'activesupport', '~> 4.2.6' +  end +end diff --git a/vendor/acme-client/LICENSE.txt b/vendor/acme-client/LICENSE.txt new file mode 100644 index 0000000..73b96b4 --- /dev/null +++ b/vendor/acme-client/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Charles Barbier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/acme-client/README.md b/vendor/acme-client/README.md new file mode 100644 index 0000000..2047885 --- /dev/null +++ b/vendor/acme-client/README.md @@ -0,0 +1,168 @@ +# Acme::Client +[](https://travis-ci.org/unixcharles/acme-client) + +`acme-client` is a client implementation of the [ACME](https://letsencrypt.github.io/acme-spec) protocol in Ruby. + +You can find the ACME reference implementations of the [server](https://github.com/letsencrypt/boulder) in Go and the [client](https://github.com/letsencrypt/letsencrypt) in Python. + +ACME is part of the [Letsencrypt](https://letsencrypt.org/) project, which goal is to provide free SSL/TLS certificates with  automation of the acquiring and renewal process. + +## Installation + +Via Rubygems: + +	$ gem install acme-client + +Or add it to a Gemfile: + +```ruby +gem 'acme-client' +``` + +## Usage + +### Register client + +In order to authenticate our client, we have to create an account for it. + +```ruby +# We're going to need a private key. +require 'openssl' +private_key = OpenSSL::PKey::RSA.new(4096) + +# We need an ACME server to talk to, see github.com/letsencrypt/boulder +# WARNING: This endpoint is the production endpoint, which is rate limited and will produce valid certificates. +# You should probably use the staging endpoint for all your experimentation: +# endpoint = 'https://acme-staging.api.letsencrypt.org/' +endpoint = 'https://acme-v01.api.letsencrypt.org/' + +# Initialize the client +require 'acme-client' +client = Acme::Client.new(private_key: private_key, endpoint: endpoint, connection_options: { request: { open_timeout: 5, timeout: 5 } }) + +# If the private key is not known to the server, we need to register it for the first time. +registration = client.register(contact: 'mailto:contact@example.com') + +# You may need to agree to the terms of service (that's up the to the server to require it or not but boulder does by default) +registration.agree_terms +``` + +### Authorize for domain + +Before you are able to obtain certificates for your domain, you have to prove that you are in control of it. + +```ruby +authorization = client.authorize(domain: 'example.org') + +# If authorization.status returns 'valid' here you can already get a certificate +# and _must not_ try to solve another challenge. +authorization.status # => 'pending' + +# You can can store the authorization's URI to fully recover it and +# any associated challenges via Acme::Client#fetch_authorization. +authorization.uri # => '...' + +# This example is using the http-01 challenge type. Other challenges are dns-01 or tls-sni-01. +challenge = authorization.http01 + +# The http-01 method will require you to respond to a HTTP request. + +# You can retrieve the challenge token +challenge.token # => "some_token" + +# You can retrieve the expected path for the file. +challenge.filename # => ".well-known/acme-challenge/:some_token" + +# You can generate the body of the expected response. +challenge.file_content # => 'string token and JWK thumbprint' + +# You are not required to send a Content-Type. This method will return the right Content-Type should you decide to include one. +challenge.content_type + +# Save the file. We'll create a public directory to serve it from, and inside it we'll create the challenge file. +FileUtils.mkdir_p( File.join( 'public', File.dirname( challenge.filename ) ) ) + +# We'll write the content of the file +File.write( File.join( 'public', challenge.filename), challenge.file_content ) + +# Optionally save the challenge for use at another time (eg: by a background job processor) +File.write('challenge', challenge.to_h.to_json) + +# The challenge file can be served with a Ruby webserver. +# You can run a webserver in another console for that purpose. You may need to forward ports on your router. +# +# $ ruby -run -e httpd public -p 8080 --bind-address 0.0.0.0 + +# Load a saved challenge. This is only required if you need to reuse a saved challenge as outlined above. +challenge = client.challenge_from_hash(JSON.parse(File.read('challenge'))) + +# Once you are ready to serve the confirmation request you can proceed. +challenge.request_verification # => true +challenge.authorization.verify_status # => 'pending' + +# Wait a bit for the server to make the request, or just blink. It should be fast. +sleep(1) + +# Rely on authorization.verify_status more than on challenge.verify_status, +# if the former is 'valid' you can already issue a certificate and the status of +# the challenge is not relevant and in fact may never change from pending. +challenge.authorization.verify_status # => 'valid' +challenge.error # => nil + +# If authorization.verify_status is 'invalid', you can get at the error +# message only through the failed challenge. +authorization.verify_status # => 'invalid' +authorization.http01.error # => {"type" => "...", "detail" => "..."} +``` + +### Obtain a certificate + +Now that your account is authorized for the domain, you should be able to obtain a certificate for it. + +```ruby +# We're going to need a certificate signing request. If not explicitly +# specified, the first name listed becomes the common name. +csr = Acme::Client::CertificateRequest.new(names: %w[example.org www.example.org]) + +# We can now request a certificate. You can pass anything that returns +# a valid DER encoded CSR when calling to_der on it. For example an +# OpenSSL::X509::Request should work too. +certificate = client.new_certificate(csr) # => #<Acme::Client::Certificate ....> + +# Save the certificate and the private key to files +File.write("privkey.pem", certificate.request.private_key.to_pem) +File.write("cert.pem", certificate.to_pem) +File.write("chain.pem", certificate.chain_to_pem) +File.write("fullchain.pem", certificate.fullchain_to_pem) + +# Start a webserver, using your shiny new certificate +# ruby -r openssl -r webrick -r 'webrick/https' -e "s = WEBrick::HTTPServer.new( +#   :Port => 8443, +#   :DocumentRoot => Dir.pwd, +#   :SSLEnable => true, +#   :SSLPrivateKey => OpenSSL::PKey::RSA.new( File.read('privkey.pem') ), +#   :SSLCertificate => OpenSSL::X509::Certificate.new( File.read('cert.pem') )); trap('INT') { s.shutdown }; s.start" +``` + +# Not implemented + +- Recovery methods are not implemented. + +# Requirements + +Ruby >= 2.1 + +## Development + +All the tests use VCR to mock the interaction with the server but if you +need to record new interation against the server simply clone boulder and +run it normally with `./start.py`. + +## Pull request? + +Yes. + +## License + +[MIT License](http://opensource.org/licenses/MIT) + diff --git a/vendor/acme-client/acme-client.gemspec b/vendor/acme-client/acme-client.gemspec new file mode 100644 index 0000000..b62d60c --- /dev/null +++ b/vendor/acme-client/acme-client.gemspec @@ -0,0 +1,27 @@ +# coding: utf-8 +lib = File.expand_path('../lib', __FILE__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require 'acme/client/version' + +Gem::Specification.new do |spec| +  spec.name          = 'acme-client' +  spec.version       = Acme::Client::VERSION +  spec.authors       = ['Charles Barbier'] +  spec.email         = ['unixcharles@gmail.com'] +  spec.summary       = 'Client for the ACME protocol.' +  spec.homepage      = 'http://github.com/unixcharles/acme-client' +  spec.license       = 'MIT' + +  spec.files         = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } +  spec.require_paths = ['lib'] + +  spec.required_ruby_version = '>= 2.1.0' + +  spec.add_development_dependency 'bundler', '~> 1.6', '>= 1.6.9' +  spec.add_development_dependency 'rake', '~> 10.0' +  spec.add_development_dependency 'rspec', '~> 3.3', '>= 3.3.0' +  spec.add_development_dependency 'vcr', '~> 2.9', '>= 2.9.3' +  spec.add_development_dependency 'webmock', '~> 1.21', '>= 1.21.0' + +  spec.add_runtime_dependency 'faraday', '~> 0.9', '>= 0.9.1' +end diff --git a/vendor/acme-client/lib/acme-client.rb b/vendor/acme-client/lib/acme-client.rb new file mode 100644 index 0000000..7cc7a0a --- /dev/null +++ b/vendor/acme-client/lib/acme-client.rb @@ -0,0 +1 @@ +require 'acme/client' diff --git a/vendor/acme-client/lib/acme/client.rb b/vendor/acme-client/lib/acme/client.rb new file mode 100644 index 0000000..801479e --- /dev/null +++ b/vendor/acme-client/lib/acme/client.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require 'faraday' +require 'json' +require 'openssl' +require 'digest' +require 'forwardable' +require 'base64' +require 'time' + +module Acme; end +class Acme::Client; end + +require 'acme/client/version' +require 'acme/client/certificate' +require 'acme/client/certificate_request' +require 'acme/client/self_sign_certificate' +require 'acme/client/crypto' +require 'acme/client/resources' +require 'acme/client/faraday_middleware' +require 'acme/client/error' + +class Acme::Client +  DEFAULT_ENDPOINT = 'http://127.0.0.1:4000'.freeze +  DIRECTORY_DEFAULT = { +    'new-authz' => '/acme/new-authz', +    'new-cert' => '/acme/new-cert', +    'new-reg' => '/acme/new-reg', +    'revoke-cert' => '/acme/revoke-cert' +  }.freeze + +  def initialize(private_key:, endpoint: DEFAULT_ENDPOINT, directory_uri: nil, connection_options: {}) +    @endpoint, @private_key, @directory_uri, @connection_options = endpoint, private_key, directory_uri, connection_options +    @nonces ||= [] +    load_directory! +  end + +  attr_reader :private_key, :nonces, :operation_endpoints + +  def register(contact:) +    payload = { +      resource: 'new-reg', contact: Array(contact) +    } + +    response = connection.post(@operation_endpoints.fetch('new-reg'), payload) +    ::Acme::Client::Resources::Registration.new(self, response) +  end + +  def authorize(domain:) +    payload = { +      resource: 'new-authz', +      identifier: { +        type: 'dns', +        value: domain +      } +    } + +    response = connection.post(@operation_endpoints.fetch('new-authz'), payload) +    ::Acme::Client::Resources::Authorization.new(self, response.headers['Location'], response) +  end + +  def fetch_authorization(uri) +    response = connection.get(uri) +    ::Acme::Client::Resources::Authorization.new(self, uri, response) +  end + +  def new_certificate(csr) +    payload = { +      resource: 'new-cert', +      csr: Base64.urlsafe_encode64(csr.to_der) +    } + +    response = connection.post(@operation_endpoints.fetch('new-cert'), payload) +    ::Acme::Client::Certificate.new(OpenSSL::X509::Certificate.new(response.body), response.headers['location'], fetch_chain(response), csr) +  end + +  def revoke_certificate(certificate) +    payload = { resource: 'revoke-cert', certificate: Base64.urlsafe_encode64(certificate.to_der) } +    endpoint = @operation_endpoints.fetch('revoke-cert') +    response = connection.post(endpoint, payload) +    response.success? +  end + +  def self.revoke_certificate(certificate, *arguments) +    client = new(*arguments) +    client.revoke_certificate(certificate) +  end + +  def connection +    @connection ||= Faraday.new(@endpoint, **@connection_options) do |configuration| +      configuration.use Acme::Client::FaradayMiddleware, client: self +      configuration.adapter Faraday.default_adapter +    end +  end + +  private + +  def fetch_chain(response, limit = 10) +    links = response.headers['link'] +    if limit.zero? || links.nil? || links['up'].nil? +      [] +    else +      issuer = connection.get(links['up']) +      [OpenSSL::X509::Certificate.new(issuer.body), *fetch_chain(issuer, limit - 1)] +    end +  end + +  def load_directory! +    @operation_endpoints = if @directory_uri +      response = connection.get(@directory_uri) +      body = response.body +      { +        'new-reg' => body.fetch('new-reg'), +        'new-authz' => body.fetch('new-authz'), +        'new-cert' => body.fetch('new-cert'), +        'revoke-cert' => body.fetch('revoke-cert'), +      } +    else +      DIRECTORY_DEFAULT +    end +  end +end diff --git a/vendor/acme-client/lib/acme/client/certificate.rb b/vendor/acme-client/lib/acme/client/certificate.rb new file mode 100644 index 0000000..6c68cc5 --- /dev/null +++ b/vendor/acme-client/lib/acme/client/certificate.rb @@ -0,0 +1,30 @@ +class Acme::Client::Certificate +  extend Forwardable + +  attr_reader :x509, :x509_chain, :request, :private_key, :url + +  def_delegators :x509, :to_pem, :to_der + +  def initialize(certificate, url, chain, request) +    @x509 = certificate +    @url = url +    @x509_chain = chain +    @request = request +  end + +  def chain_to_pem +    x509_chain.map(&:to_pem).join +  end + +  def x509_fullchain +    [x509, *x509_chain] +  end + +  def fullchain_to_pem +    x509_fullchain.map(&:to_pem).join +  end + +  def common_name +    x509.subject.to_a.find { |name, _, _| name == 'CN' }[1] +  end +end diff --git a/vendor/acme-client/lib/acme/client/certificate_request.rb b/vendor/acme-client/lib/acme/client/certificate_request.rb new file mode 100644 index 0000000..8eae0c6 --- /dev/null +++ b/vendor/acme-client/lib/acme/client/certificate_request.rb @@ -0,0 +1,111 @@ +class Acme::Client::CertificateRequest +  extend Forwardable + +  DEFAULT_KEY_LENGTH = 2048 +  DEFAULT_DIGEST = OpenSSL::Digest::SHA256 +  SUBJECT_KEYS = { +    common_name:         'CN', +    country_name:        'C', +    organization_name:   'O', +    organizational_unit: 'OU', +    state_or_province:   'ST', +    locality_name:       'L' +  }.freeze + +  SUBJECT_TYPES = { +    'CN' => OpenSSL::ASN1::UTF8STRING, +    'C'  => OpenSSL::ASN1::UTF8STRING, +    'O'  => OpenSSL::ASN1::UTF8STRING, +    'OU' => OpenSSL::ASN1::UTF8STRING, +    'ST' => OpenSSL::ASN1::UTF8STRING, +    'L'  => OpenSSL::ASN1::UTF8STRING +  }.freeze + +  attr_reader :private_key, :common_name, :names, :subject + +  def_delegators :csr, :to_pem, :to_der + +  def initialize(common_name: nil, names: [], private_key: generate_private_key, subject: {}, digest: DEFAULT_DIGEST.new) +    @digest = digest +    @private_key = private_key +    @subject = normalize_subject(subject) +    @common_name = common_name || @subject[SUBJECT_KEYS[:common_name]] || @subject[:common_name] +    @names = names.to_a.dup +    normalize_names +    @subject[SUBJECT_KEYS[:common_name]] ||= @common_name +    validate_subject +  end + +  def csr +    @csr ||= generate +  end + +  private + +  def generate_private_key +    OpenSSL::PKey::RSA.new(DEFAULT_KEY_LENGTH) +  end + +  def normalize_subject(subject) +    @subject = subject.each_with_object({}) do |(key, value), hash| +      hash[SUBJECT_KEYS.fetch(key, key)] = value.to_s +    end +  end + +  def normalize_names +    if @common_name +      @names.unshift(@common_name) unless @names.include?(@common_name) +    else +      raise ArgumentError, 'No common name and no list of names given' if @names.empty? +      @common_name = @names.first +    end +  end + +  def validate_subject +    validate_subject_attributes +    validate_subject_common_name +  end + +  def validate_subject_attributes +    extra_keys = @subject.keys - SUBJECT_KEYS.keys - SUBJECT_KEYS.values +    return if extra_keys.empty? +    raise ArgumentError, "Unexpected subject attributes given: #{extra_keys.inspect}" +  end + +  def validate_subject_common_name +    return if @common_name == @subject[SUBJECT_KEYS[:common_name]] +    raise ArgumentError, 'Conflicting common name given in arguments and subject' +  end + +  def generate +    OpenSSL::X509::Request.new.tap do |csr| +      csr.public_key = @private_key.public_key +      csr.subject = generate_subject +      csr.version = 2 +      add_extension(csr) +      csr.sign @private_key, @digest +    end +  end + +  def generate_subject +    OpenSSL::X509::Name.new( +      @subject.map {|name, value| +        [name, value, SUBJECT_TYPES[name]] +      } +    ) +  end + +  def add_extension(csr) +    return if @names.size <= 1 + +    extension = OpenSSL::X509::ExtensionFactory.new.create_extension( +      'subjectAltName', @names.map { |name| "DNS:#{name}" }.join(', '), false +    ) +    csr.add_attribute( +      OpenSSL::X509::Attribute.new( +        'extReq', +        OpenSSL::ASN1::Set.new([OpenSSL::ASN1::Sequence.new([extension])]) +      ) +    ) +  end +end diff --git a/vendor/acme-client/lib/acme/client/crypto.rb b/vendor/acme-client/lib/acme/client/crypto.rb new file mode 100644 index 0000000..dfa5cdc --- /dev/null +++ b/vendor/acme-client/lib/acme/client/crypto.rb @@ -0,0 +1,98 @@ +class Acme::Client::Crypto +  attr_reader :private_key + +  def initialize(private_key) +    @private_key = private_key +  end + +  def generate_signed_jws(header:, payload:) +    header = { typ: 'JWT', alg: jws_alg, jwk: jwk }.merge(header) + +    encoded_header = urlsafe_base64(header.to_json) +    encoded_payload = urlsafe_base64(payload.to_json) +    signature_data = "#{encoded_header}.#{encoded_payload}" + +    signature = private_key.sign digest, signature_data +    encoded_signature = urlsafe_base64(signature) + +    { +      protected: encoded_header, +      payload: encoded_payload, +      signature: encoded_signature +    }.to_json +  end + +  def thumbprint +    urlsafe_base64 digest.digest(jwk.to_json) +  end + +  def digest +    OpenSSL::Digest::SHA256.new +  end + +  def urlsafe_base64(data) +    Base64.urlsafe_encode64(data).sub(/[\s=]*\z/, '') +  end + +  private + +  def jws_alg +    { 'RSA' => 'RS256', 'EC' => 'ES256' }.fetch(jwk[:kty]) +  end + +  def jwk +    @jwk ||= case private_key +             when OpenSSL::PKey::RSA +               rsa_jwk +             when OpenSSL::PKey::EC +               ec_jwk +             else +               raise ArgumentError, "Can't handle #{private_key} as private key, only OpenSSL::PKey::RSA and OpenSSL::PKey::EC" +    end +  end + +  def rsa_jwk +    { +      e: urlsafe_base64(public_key.e.to_s(2)), +      kty: 'RSA', +      n: urlsafe_base64(public_key.n.to_s(2)) +    } +  end + +  def ec_jwk +    { +      crv: curve_name, +      kty: 'EC', +      x: urlsafe_base64(coordinates[:x].to_s(2)), +      y: urlsafe_base64(coordinates[:y].to_s(2)) +    } +  end + +  def curve_name +    { +      'prime256v1' => 'P-256', +      'secp384r1' => 'P-384', +      'secp521r1' => 'P-521' +    }.fetch(private_key.group.curve_name) { raise ArgumentError, 'Unknown EC curve' } +  end + +  # rubocop:disable Metrics/AbcSize +  def coordinates +    @coordinates ||= begin +      hex = public_key.to_bn.to_s(16) +      data_len = hex.length - 2 +      hex_x = hex[2, data_len / 2] +      hex_y = hex[2 + data_len / 2, data_len / 2] + +      { +        x: OpenSSL::BN.new([hex_x].pack('H*'), 2), +        y: OpenSSL::BN.new([hex_y].pack('H*'), 2) +      } +    end +  end +  # rubocop:enable Metrics/AbcSize + +  def public_key +    @public_key ||= private_key.public_key +  end +end diff --git a/vendor/acme-client/lib/acme/client/error.rb b/vendor/acme-client/lib/acme/client/error.rb new file mode 100644 index 0000000..2b35623 --- /dev/null +++ b/vendor/acme-client/lib/acme/client/error.rb @@ -0,0 +1,16 @@ +class Acme::Client::Error < StandardError +  class NotFound < Acme::Client::Error; end +  class BadCSR < Acme::Client::Error; end +  class BadNonce < Acme::Client::Error; end +  class Connection < Acme::Client::Error; end +  class Dnssec < Acme::Client::Error; end +  class Malformed < Acme::Client::Error; end +  class ServerInternal < Acme::Client::Error; end +  class Acme::Tls < Acme::Client::Error; end +  class Unauthorized < Acme::Client::Error; end +  class UnknownHost < Acme::Client::Error; end +  class Timeout < Acme::Client::Error; end +  class RateLimited < Acme::Client::Error; end +  class RejectedIdentifier < Acme::Client::Error; end +  class UnsupportedIdentifier < Acme::Client::Error; end +end diff --git a/vendor/acme-client/lib/acme/client/faraday_middleware.rb b/vendor/acme-client/lib/acme/client/faraday_middleware.rb new file mode 100644 index 0000000..21e29c9 --- /dev/null +++ b/vendor/acme-client/lib/acme/client/faraday_middleware.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +class Acme::Client::FaradayMiddleware < Faraday::Middleware +  attr_reader :env, :response, :client + +  repo_url = 'https://github.com/unixcharles/acme-client' +  USER_AGENT = "Acme::Client v#{Acme::Client::VERSION} (#{repo_url})".freeze + +  def initialize(app, client:) +    super(app) +    @client = client +  end + +  def call(env) +    @env = env +    @env[:request_headers]['User-Agent'] = USER_AGENT +    @env.body = crypto.generate_signed_jws(header: { nonce: pop_nonce }, payload: env.body) +    @app.call(env).on_complete { |response_env| on_complete(response_env) } +  rescue Faraday::TimeoutError +    raise Acme::Client::Error::Timeout +  end + +  def on_complete(env) +    @env = env + +    raise_on_not_found! +    store_nonce +    env.body = decode_body +    env.response_headers['Link'] = decode_link_headers + +    return if env.success? + +    raise_on_error! +  end + +  private + +  def raise_on_not_found! +    raise Acme::Client::Error::NotFound, env.url.to_s if env.status == 404 +  end + +  def raise_on_error! +    raise error_class, error_message +  end + +  def error_message +    if env.body.is_a? Hash +      env.body['detail'] +    else +      "Error message: #{env.body}" +    end +  end + +  def error_class +    if error_name && !error_name.empty? && Acme::Client::Error.const_defined?(error_name) +      Object.const_get("Acme::Client::Error::#{error_name}") +    else +      Acme::Client::Error +    end +  end + +  def error_name +    @error_name ||= begin +      return unless env.body.is_a?(Hash) +      return unless env.body.key?('type') + +      env.body['type'].gsub('urn:acme:error:', '').split(/[_-]/).map(&:capitalize).join +    end +  end + +  def decode_body +    content_type = env.response_headers['Content-Type'] + +    if content_type == 'application/json' || content_type == 'application/problem+json' +      JSON.load(env.body) +    else +      env.body +    end +  end + +  LINK_MATCH = /<(.*?)>;rel="([\w-]+)"/ + +  def decode_link_headers +    return unless env.response_headers.key?('Link') +    link_header = env.response_headers['Link'] + +    links = link_header.split(', ').map { |entry| +      _, link, name = *entry.match(LINK_MATCH) +      [name, link] +    } + +    Hash[*links.flatten] +  end + +  def store_nonce +    nonces << env.response_headers['replay-nonce'] +  end + +  def pop_nonce +    if nonces.empty? +      get_nonce +    else +      nonces.pop +    end +  end + +  def get_nonce +    response = Faraday.head(env.url, nil, 'User-Agent' => USER_AGENT) +    response.headers['replay-nonce'] +  end + +  def nonces +    client.nonces +  end + +  def private_key +    client.private_key +  end + +  def crypto +    @crypto ||= Acme::Client::Crypto.new(private_key) +  end +end diff --git a/vendor/acme-client/lib/acme/client/resources.rb b/vendor/acme-client/lib/acme/client/resources.rb new file mode 100644 index 0000000..ad55688 --- /dev/null +++ b/vendor/acme-client/lib/acme/client/resources.rb @@ -0,0 +1,5 @@ +module Acme::Client::Resources; end + +require 'acme/client/resources/registration' +require 'acme/client/resources/challenges' +require 'acme/client/resources/authorization' diff --git a/vendor/acme-client/lib/acme/client/resources/authorization.rb b/vendor/acme-client/lib/acme/client/resources/authorization.rb new file mode 100644 index 0000000..9ca2e76 --- /dev/null +++ b/vendor/acme-client/lib/acme/client/resources/authorization.rb @@ -0,0 +1,44 @@ +class Acme::Client::Resources::Authorization +  HTTP01 = Acme::Client::Resources::Challenges::HTTP01 +  DNS01 = Acme::Client::Resources::Challenges::DNS01 +  TLSSNI01 = Acme::Client::Resources::Challenges::TLSSNI01 + +  attr_reader :client, :uri, :domain, :status, :expires, :http01, :dns01, :tls_sni01 + +  def initialize(client, uri, response) +    @client = client +    @uri = uri +    assign_attributes(response.body) +  end + +  def verify_status +    response = @client.connection.get(@uri) + +    assign_attributes(response.body) +    status +  end + +  private + +  def assign_attributes(body) +    @expires = Time.iso8601(body['expires']) if body.key? 'expires' +    @domain = body['identifier']['value'] +    @status = body['status'] +    assign_challenges(body['challenges']) +  end + +  def assign_challenges(challenges) +    challenges.each do |attributes| +      challenge = case attributes.fetch('type') +                  when 'http-01' +                    @http01 ||= HTTP01.new(self) +                  when 'dns-01' +                    @dns01 ||= DNS01.new(self) +                  when 'tls-sni-01' +                    @tls_sni01 ||= TLSSNI01.new(self) +      end + +      challenge.assign_attributes(attributes) if challenge +    end +  end +end diff --git a/vendor/acme-client/lib/acme/client/resources/challenges.rb b/vendor/acme-client/lib/acme/client/resources/challenges.rb new file mode 100644 index 0000000..ec92d47 --- /dev/null +++ b/vendor/acme-client/lib/acme/client/resources/challenges.rb @@ -0,0 +1,6 @@ +module Acme::Client::Resources::Challenges; end + +require 'acme/client/resources/challenges/base' +require 'acme/client/resources/challenges/http01' +require 'acme/client/resources/challenges/dns01' +require 'acme/client/resources/challenges/tls_sni01' diff --git a/vendor/acme-client/lib/acme/client/resources/challenges/base.rb b/vendor/acme-client/lib/acme/client/resources/challenges/base.rb new file mode 100644 index 0000000..c78c74e --- /dev/null +++ b/vendor/acme-client/lib/acme/client/resources/challenges/base.rb @@ -0,0 +1,43 @@ +class Acme::Client::Resources::Challenges::Base +  attr_reader :authorization, :status, :uri, :token, :error + +  def initialize(authorization) +    @authorization = authorization +  end + +  def client +    authorization.client +  end + +  def verify_status +    authorization.verify_status + +    status +  end + +  def request_verification +    response = client.connection.post(@uri, resource: 'challenge', type: challenge_type, keyAuthorization: authorization_key) +    response.success? +  end + +  def assign_attributes(attributes) +    @status = attributes.fetch('status', 'pending') +    @uri = attributes.fetch('uri') +    @token = attributes.fetch('token') +    @error = attributes['error'] +  end + +  private + +  def challenge_type +    self.class::CHALLENGE_TYPE +  end + +  def authorization_key +    "#{token}.#{crypto.thumbprint}" +  end + +  def crypto +    @crypto ||= Acme::Client::Crypto.new(client.private_key) +  end +end diff --git a/vendor/acme-client/lib/acme/client/resources/challenges/dns01.rb b/vendor/acme-client/lib/acme/client/resources/challenges/dns01.rb new file mode 100644 index 0000000..543f438 --- /dev/null +++ b/vendor/acme-client/lib/acme/client/resources/challenges/dns01.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Acme::Client::Resources::Challenges::DNS01 < Acme::Client::Resources::Challenges::Base +  CHALLENGE_TYPE = 'dns-01'.freeze +  RECORD_NAME = '_acme-challenge'.freeze +  RECORD_TYPE = 'TXT'.freeze + +  def record_name +    RECORD_NAME +  end + +  def record_type +    RECORD_TYPE +  end + +  def record_content +    crypto.urlsafe_base64(crypto.digest.digest(authorization_key)) +  end +end diff --git a/vendor/acme-client/lib/acme/client/resources/challenges/http01.rb b/vendor/acme-client/lib/acme/client/resources/challenges/http01.rb new file mode 100644 index 0000000..4966091 --- /dev/null +++ b/vendor/acme-client/lib/acme/client/resources/challenges/http01.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Acme::Client::Resources::Challenges::HTTP01 < Acme::Client::Resources::Challenges::Base +  CHALLENGE_TYPE = 'http-01'.freeze +  CONTENT_TYPE = 'text/plain'.freeze + +  def content_type +    CONTENT_TYPE +  end + +  def file_content +    authorization_key +  end + +  def filename +    ".well-known/acme-challenge/#{token}" +  end +end diff --git a/vendor/acme-client/lib/acme/client/resources/challenges/tls_sni01.rb b/vendor/acme-client/lib/acme/client/resources/challenges/tls_sni01.rb new file mode 100644 index 0000000..8f455f5 --- /dev/null +++ b/vendor/acme-client/lib/acme/client/resources/challenges/tls_sni01.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class Acme::Client::Resources::Challenges::TLSSNI01 < Acme::Client::Resources::Challenges::Base +  CHALLENGE_TYPE = 'tls-sni-01'.freeze + +  def hostname +    digest = crypto.digest.hexdigest(authorization_key) +    "#{digest[0..31]}.#{digest[32..64]}.acme.invalid" +  end + +  def certificate +    self_sign_certificate.certificate +  end + +  def private_key +    self_sign_certificate.private_key +  end + +  private + +  def self_sign_certificate +    @self_sign_certificate ||= Acme::Client::SelfSignCertificate.new(subject_alt_names: [hostname]) +  end +end diff --git a/vendor/acme-client/lib/acme/client/resources/registration.rb b/vendor/acme-client/lib/acme/client/resources/registration.rb new file mode 100644 index 0000000..b7a4c11 --- /dev/null +++ b/vendor/acme-client/lib/acme/client/resources/registration.rb @@ -0,0 +1,37 @@ +class Acme::Client::Resources::Registration +  attr_reader :id, :key, :contact, :uri, :next_uri, :recover_uri, :term_of_service_uri + +  def initialize(client, response) +    @client = client +    @uri = response.headers['location'] +    assign_links(response.headers['Link']) +    assign_attributes(response.body) +  end + +  def get_terms +    return unless @term_of_service_uri + +    @client.connection.get(@term_of_service_uri).body +  end + +  def agree_terms +    return true unless @term_of_service_uri + +    response = @client.connection.post(@uri, resource: 'reg', agreement: @term_of_service_uri) +    response.success? +  end + +  private + +  def assign_links(links) +    @next_uri = links['next'] +    @recover_uri = links['recover'] +    @term_of_service_uri = links['terms-of-service'] +  end + +  def assign_attributes(body) +    @id = body['id'] +    @key = body['key'] +    @contact = body['contact'] +  end +end diff --git a/vendor/acme-client/lib/acme/client/self_sign_certificate.rb b/vendor/acme-client/lib/acme/client/self_sign_certificate.rb new file mode 100644 index 0000000..2e7d98c --- /dev/null +++ b/vendor/acme-client/lib/acme/client/self_sign_certificate.rb @@ -0,0 +1,60 @@ +class Acme::Client::SelfSignCertificate +  attr_reader :private_key, :subject_alt_names, :not_before, :not_after + +  extend Forwardable +  def_delegators :certificate, :to_pem, :to_der + +  def initialize(subject_alt_names:, not_before: default_not_before, not_after: default_not_after, private_key: generate_private_key) +    @private_key = private_key +    @subject_alt_names = subject_alt_names +    @not_before = not_before +    @not_after = not_after +  end + +  def certificate +    @certificate ||= begin +      certificate = generate_certificate + +      extension_factory = generate_extension_factory(certificate) +      subject_alt_name_entry = subject_alt_names.map { |d| "DNS: #{d}" }.join(',') +      subject_alt_name_extension = extension_factory.create_extension('subjectAltName', subject_alt_name_entry) +      certificate.add_extension(subject_alt_name_extension) + +      certificate.sign(private_key, digest) +    end +  end + +  private + +  def generate_private_key +    OpenSSL::PKey::RSA.new(2048) +  end + +  def default_not_before +    Time.now - 3600 +  end + +  def default_not_after +    Time.now + 30 * 24 * 3600 +  end + +  def digest +    OpenSSL::Digest::SHA256.new +  end + +  def generate_certificate +    certificate = OpenSSL::X509::Certificate.new +    certificate.not_before = not_before +    certificate.not_after = not_after +    certificate.public_key = private_key.public_key +    certificate.version = 2 +    certificate +  end + +  def generate_extension_factory(certificate) +    extension_factory = OpenSSL::X509::ExtensionFactory.new +    extension_factory.subject_certificate = certificate +    extension_factory.issuer_certificate = certificate +    extension_factory +  end +end diff --git a/vendor/acme-client/lib/acme/client/version.rb b/vendor/acme-client/lib/acme/client/version.rb new file mode 100644 index 0000000..c989c12 --- /dev/null +++ b/vendor/acme-client/lib/acme/client/version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Acme +  class Client +    VERSION = '0.4.1'.freeze +  end +end | 
