diff options
author | elijah <elijah@riseup.net> | 2016-08-30 23:27:39 -0700 |
---|---|---|
committer | elijah <elijah@riseup.net> | 2016-08-30 23:27:39 -0700 |
commit | cd809a6b69790b48344abfaa294edd8c4d4c7231 (patch) | |
tree | 80effc5c22179bd678a5d5d3873c81153fc8dbcf /vendor/acme-client/lib/acme | |
parent | d324b3e4af6869414ee928c6f507093791c10c1c (diff) |
added acme-client gem
Diffstat (limited to 'vendor/acme-client/lib/acme')
16 files changed, 763 insertions, 0 deletions
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 |