class Acme::Client

Constants

CONTENT_TYPES
DEFAULT_DIRECTORY
USER_AGENT
VERSION

Attributes

jwk[R]
nonces[R]

Public Class Methods

new(jwk: nil, kid: nil, private_key: nil, directory: DEFAULT_DIRECTORY, connection_options: {}, bad_nonce_retry: 0) click to toggle source
# File lib/acme/client.rb, line 34
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_url = URI(directory)
  @nonces ||= []
end

Public Instance Methods

account() click to toggle source
# File lib/acme/client.rb, line 123
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
account_deactivate() click to toggle source
# File lib/acme/client.rb, line 95
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
account_key_change(new_private_key: nil, new_jwk: nil) click to toggle source
# File lib/acme/client.rb, line 101
def account_key_change(new_private_key: nil, new_jwk: nil)
  if new_private_key.nil? && new_jwk.nil?
    raise ArgumentError, 'must specify new_jwk or new_private_key'
  end
  old_jwk = jwk
  new_jwk ||= Acme::Client::JWK.from_private_key(new_private_key)

  inner_payload_header = {
    url: endpoint_for(:key_change)
  }
  inner_payload = {
    account: kid,
    oldKey: old_jwk.to_h
  }
  payload = JSON.parse(new_jwk.jws(header: inner_payload_header, payload: inner_payload))

  response = post(endpoint_for(:key_change), payload: payload, mode: :kid)
  arguments = attributes_from_account_response(response)
  @jwk = new_jwk
  Acme::Client::Resources::Account.new(self, url: kid, **arguments)
end
account_update(contact: nil, terms_of_service_agreed: nil) click to toggle source
# File lib/acme/client.rb, line 85
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
authorization(url:) click to toggle source
# File lib/acme/client.rb, line 186
def authorization(url:)
  response = post_as_get(url)
  arguments = attributes_from_authorization_response(response)
  Acme::Client::Resources::Authorization.new(self, url: url, **arguments)
end
caa_identities() click to toggle source
# File lib/acme/client.rb, line 248
def caa_identities
  directory.caa_identities
end
certificate(url:, force_chain: nil) click to toggle source
# File lib/acme/client.rb, line 166
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
challenge(url:) click to toggle source
# File lib/acme/client.rb, line 198
def challenge(url:)
  response = post_as_get(url)
  arguments = attributes_from_challenge_response(response)
  Acme::Client::Resources::Challenges.new(self, **arguments)
end
deactivate_authorization(url:) click to toggle source
# File lib/acme/client.rb, line 192
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
directory() click to toggle source
# File lib/acme/client.rb, line 232
def directory
  @directory ||= load_directory
end
external_account_required() click to toggle source
# File lib/acme/client.rb, line 252
def external_account_required
  directory.external_account_required
end
finalize(url:, csr:) click to toggle source
# File lib/acme/client.rb, line 155
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
get_nonce() click to toggle source
# File lib/acme/client.rb, line 225
def get_nonce
  http_client = Acme::Client::HTTPClient.new_connection(url: endpoint_for(:new_nonce), options: @connection_options)
  response = http_client.head(nil, nil)
  nonces << response.headers['replay-nonce']
  true
end
kid() click to toggle source
# File lib/acme/client.rb, line 134
def kid
  @kid ||= account.kid
end
meta() click to toggle source
# File lib/acme/client.rb, line 236
def meta
  directory.meta
end
new_account(contact:, terms_of_service_agreed: nil, external_account_binding: nil) click to toggle source
# File lib/acme/client.rb, line 53
def new_account(contact:, terms_of_service_agreed: nil, external_account_binding: nil)
  new_account_endpoint = endpoint_for(:new_account)
  payload = {
    contact: Array(contact)
  }

  if terms_of_service_agreed
    payload[:termsOfServiceAgreed] = terms_of_service_agreed
  end

  if external_account_binding
    kid, hmac_key = external_account_binding.values_at(:kid, :hmac_key)
    if kid.nil? || hmac_key.nil?
      raise ArgumentError, 'must specify kid and hmac_key key for external_account_binding'
    end

    hmac = Acme::Client::JWK::HMAC.new(Base64.urlsafe_decode64(hmac_key))
    external_account_payload = hmac.jws(header: { kid: kid, url: new_account_endpoint }, payload: @jwk)
    payload[:externalAccountBinding] = JSON.parse(external_account_payload)
  end

  response = post(new_account_endpoint, 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
new_order(identifiers:, not_before: nil, not_after: nil) click to toggle source
# File lib/acme/client.rb, line 138
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
order(url:) click to toggle source
# File lib/acme/client.rb, line 149
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
request_challenge_validation(url:, key_authorization: nil) click to toggle source
# File lib/acme/client.rb, line 204
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
revoke(certificate:, reason: nil) click to toggle source
# File lib/acme/client.rb, line 210
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
terms_of_service() click to toggle source
# File lib/acme/client.rb, line 240
def terms_of_service
  directory.terms_of_service
end
website() click to toggle source
# File lib/acme/client.rb, line 244
def website
  directory.website
end

Private Instance Methods

attributes_from_account_response(response) click to toggle source
# File lib/acme/client.rb, line 284
def attributes_from_account_response(response)
  extract_attributes(
    response.body,
    :status,
    [:term_of_service, 'termsOfServiceAgreed'],
    :contact
  )
end
attributes_from_authorization_response(response) click to toggle source
# File lib/acme/client.rb, line 308
def attributes_from_authorization_response(response)
  extract_attributes(response.body, :identifier, :status, :expires, :challenges, :wildcard)
end
attributes_from_challenge_response(response) click to toggle source
# File lib/acme/client.rb, line 312
def attributes_from_challenge_response(response)
  extract_attributes(response.body, :status, :url, :token, :type, :error)
end
attributes_from_order_response(response) click to toggle source
# File lib/acme/client.rb, line 293
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
connection_for(url:, mode:) click to toggle source
# File lib/acme/client.rb, line 348
def connection_for(url:, mode:)
  uri = URI(url)
  endpoint = "#{uri.scheme}://#{uri.hostname}:#{uri.port}"

  @connections ||= {}
  @connections[mode] ||= {}
  @connections[mode][endpoint] ||= Acme::Client::HTTPClient.new_acme_connection(
    url: URI(endpoint), mode: mode, client: self, options: @connection_options, bad_nonce_retry: @bad_nonce_retry
  )
end
download(url, format:) click to toggle source
# File lib/acme/client.rb, line 340
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
endpoint_for(key) click to toggle source
# File lib/acme/client.rb, line 359
def endpoint_for(key)
  directory.endpoint_for(key)
end
extract_attributes(input, *attributes) click to toggle source
# File lib/acme/client.rb, line 316
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
fetch_directory() click to toggle source
# File lib/acme/client.rb, line 262
def fetch_directory
  response = get(@directory_url)
  response.body
rescue JSON::ParserError => exception
  raise Acme::Client::Error::InvalidDirectory,
    "Invalid directory url\n#{@directory_url} did not return a valid directory\n#{exception.inspect}"
end
get(url, mode: :get) click to toggle source
# File lib/acme/client.rb, line 335
def get(url, mode: :get)
  connection = connection_for(url: url, mode: mode)
  connection.get(url)
end
load_directory() click to toggle source
# File lib/acme/client.rb, line 258
def load_directory
  Acme::Client::Resources::Directory.new(self, directory: fetch_directory)
end
post(url, payload: {}, mode: :kid) click to toggle source
# File lib/acme/client.rb, line 325
def post(url, payload: {}, mode: :kid)
  connection = connection_for(url: url, mode: mode)
  connection.post(url, payload)
end
post_as_get(url, mode: :kid) click to toggle source
# File lib/acme/client.rb, line 330
def post_as_get(url, mode: :kid)
  connection = connection_for(url: url, mode: mode)
  connection.post(url, nil)
end
prepare_order_identifiers(identifiers) click to toggle source
# File lib/acme/client.rb, line 270
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