# frozen_string_literal: true require 'faraday' require 'json' require 'openssl' require 'digest' require 'forwardable' require 'base64' require 'time' require 'uri' module Acme; end class Acme::Client; end require 'acme/client/version' require 'acme/client/certificate_request' require 'acme/client/self_sign_certificate' require 'acme/client/resources' require 'acme/client/faraday_middleware' require 'acme/client/jwk' require 'acme/client/error' require 'acme/client/util' require 'acme/client/chain_identifier' class Acme::Client DEFAULT_DIRECTORY = 'http://127.0.0.1:4000/directory'.freeze repo_url = 'https://github.com/unixcharles/acme-client' USER_AGENT = "Acme::Client v#{Acme::Client::VERSION} (#{repo_url})".freeze CONTENT_TYPES = { pem: 'application/pem-certificate-chain' } def initialize(jwk: nil, kid: nil, private_key: nil, directory: DEFAULT_DIRECTORY, connection_options: {}, bad_nonce_retry: 0) if jwk.nil? && private_key.nil? raise ArgumentError, 'must specify jwk or private_key' end @jwk = if jwk jwk else Acme::Client::JWK.from_private_key(private_key) end @kid, @connection_options = kid, connection_options @bad_nonce_retry = bad_nonce_retry @directory = Acme::Client::Resources::Directory.new(URI(directory), @connection_options) @nonces ||= [] end attr_reader :jwk, :nonces def new_account(contact:, terms_of_service_agreed: nil) payload = { contact: Array(contact) } if terms_of_service_agreed payload[:termsOfServiceAgreed] = terms_of_service_agreed end response = post(endpoint_for(:new_account), payload: payload, mode: :jws) @kid = response.headers.fetch(:location) if response.body.nil? || response.body.empty? account else arguments = attributes_from_account_response(response) Acme::Client::Resources::Account.new(self, url: @kid, **arguments) end end def account_update(contact: nil, terms_of_service_agreed: nil) payload = {} payload[:contact] = Array(contact) if contact payload[:termsOfServiceAgreed] = terms_of_service_agreed if terms_of_service_agreed response = post(kid, payload: payload) arguments = attributes_from_account_response(response) Acme::Client::Resources::Account.new(self, url: kid, **arguments) end def account_deactivate response = post(kid, payload: { status: 'deactivated' }) arguments = attributes_from_account_response(response) Acme::Client::Resources::Account.new(self, url: kid, **arguments) end def account @kid ||= begin response = post(endpoint_for(:new_account), payload: { onlyReturnExisting: true }, mode: :jwk) response.headers.fetch(:location) end response = post_as_get(@kid) arguments = attributes_from_account_response(response) Acme::Client::Resources::Account.new(self, url: @kid, **arguments) end def kid @kid ||= account.kid end def new_order(identifiers:, not_before: nil, not_after: nil) payload = {} payload['identifiers'] = prepare_order_identifiers(identifiers) 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 def order(url:) response = post_as_get(url) arguments = attributes_from_order_response(response) Acme::Client::Resources::Order.new(self, **arguments.merge(url: url)) end def finalize(url:, csr:) unless csr.respond_to?(:to_der) raise ArgumentError, 'csr must respond to `#to_der`' end base64_der_csr = Acme::Client::Util.urlsafe_base64(csr.to_der) response = post(url, payload: { csr: base64_der_csr }) arguments = attributes_from_order_response(response) Acme::Client::Resources::Order.new(self, **arguments) end def certificate(url:, force_chain: nil) response = download(url, format: :pem) pem = response.body return pem if force_chain.nil? return pem if ChainIdentifier.new(pem).match_name?(force_chain) alternative_urls = Array(response.headers.dig('link', 'alternate')) alternative_urls.each do |alternate_url| response = download(alternate_url, format: :pem) pem = response.body if ChainIdentifier.new(pem).match_name?(force_chain) return pem end end raise Acme::Client::Error::ForcedChainNotFound, "Could not find any matching chain for `#{force_chain}`" end def authorization(url:) response = post_as_get(url) arguments = attributes_from_authorization_response(response) Acme::Client::Resources::Authorization.new(self, url: url, **arguments) end def deactivate_authorization(url:) response = post(url, payload: { status: 'deactivated' }) arguments = attributes_from_authorization_response(response) Acme::Client::Resources::Authorization.new(self, url: url, **arguments) end def challenge(url:) response = post_as_get(url) arguments = attributes_from_challenge_response(response) Acme::Client::Resources::Challenges.new(self, **arguments) end def request_challenge_validation(url:, key_authorization: nil) response = post(url, payload: {}) arguments = attributes_from_challenge_response(response) Acme::Client::Resources::Challenges.new(self, **arguments) end def revoke(certificate:, reason: nil) der_certificate = if certificate.respond_to?(:to_der) certificate.to_der else OpenSSL::X509::Certificate.new(certificate).to_der end base64_der_certificate = Acme::Client::Util.urlsafe_base64(der_certificate) payload = { certificate: base64_der_certificate } payload[:reason] = reason unless reason.nil? response = post(endpoint_for(:revoke_certificate), payload: payload) response.success? end def get_nonce connection = new_connection(endpoint: endpoint_for(:new_nonce)) response = connection.head(nil, nil, 'User-Agent' => USER_AGENT) nonces << response.headers['replay-nonce'] true end def meta @directory.meta end def terms_of_service @directory.terms_of_service end def website @directory.website end def caa_identities @directory.caa_identities end def external_account_required @directory.external_account_required end private def prepare_order_identifiers(identifiers) if identifiers.is_a?(Hash) [identifiers] else Array(identifiers).map do |identifier| if identifier.is_a?(String) { type: 'dns', value: identifier } else identifier end end end end def attributes_from_account_response(response) extract_attributes( response.body, :status, [:term_of_service, 'termsOfServiceAgreed'], :contact ) end def attributes_from_order_response(response) attributes = extract_attributes( response.body, :status, :expires, [:finalize_url, 'finalize'], [:authorization_urls, 'authorizations'], [:certificate_url, 'certificate'], :identifiers ) attributes[:url] = response.headers[:location] if response.headers[:location] attributes end def attributes_from_authorization_response(response) extract_attributes(response.body, :identifier, :status, :expires, :challenges, :wildcard) end def attributes_from_challenge_response(response) extract_attributes(response.body, :status, :url, :token, :type, :error) end def extract_attributes(input, *attributes) attributes .map {|fields| Array(fields) } .each_with_object({}) { |(key, field), hash| field ||= key.to_s hash[key] = input[field] } end def post(url, payload: {}, mode: :kid) connection = connection_for(url: url, mode: mode) connection.post(url, payload) end def post_as_get(url, mode: :kid) connection = connection_for(url: url, mode: mode) connection.post(url, nil) end def get(url, mode: :kid) connection = connection_for(url: url, mode: mode) connection.get(url) end def download(url, format:) connection = connection_for(url: url, mode: :kid) connection.post do |request| request.url(url) request.headers['Accept'] = CONTENT_TYPES.fetch(format) end end def connection_for(url:, mode:) uri = URI(url) endpoint = "#{uri.scheme}://#{uri.hostname}:#{uri.port}" @connections ||= {} @connections[mode] ||= {} @connections[mode][endpoint] ||= new_acme_connection(endpoint: endpoint, mode: mode) end def new_acme_connection(endpoint:, mode:) new_connection(endpoint: endpoint) do |configuration| configuration.use Acme::Client::FaradayMiddleware, client: self, mode: mode end end def new_connection(endpoint:) Faraday.new(endpoint, **@connection_options) do |configuration| if @bad_nonce_retry > 0 configuration.request(:retry, max: @bad_nonce_retry, methods: Faraday::Connection::METHODS, exceptions: [Acme::Client::Error::BadNonce]) end yield(configuration) if block_given? configuration.adapter Faraday.default_adapter end end def fetch_chain(response, limit = 10) links = response.headers['link'] if limit.zero? || links.nil? || links['up'].nil? [] else issuer = get(links['up']) [OpenSSL::X509::Certificate.new(issuer.body), *fetch_chain(issuer, limit - 1)] end end def endpoint_for(key) @directory.endpoint_for(key) end end