lib/bullion/helpers/acme.rb
# frozen_string_literal: true module Bullion # Common helper functions module Helpers # ACME-specific helper functions module Acme # Parses and verifies the incoming ACME JWT for authentication # @see https://tools.ietf.org/html/rfc8555#section-6.2 # rubocop:disable Metrics/AbcSize # rubocop:disable Metrics/MethodLength # rubocop:disable Metrics/PerceivedComplexity # rubocop:disable Metrics/CyclomaticComplexity def parse_acme_jwt(key = nil, validate_nonce: true) @header_data = extract_header_data @payload_data = extract_payload_data signature = @json_body[:signature] # check nonce if validate_nonce nonce = Models::Nonce.where(token: @header_data["nonce"]).first raise Bullion::Acme::Errors::BadNonce unless nonce nonce.destroy end jwt_data = [ @json_body[:protected], @json_body[:payload], @json_body[:signature] ].join(".") # Either use the provided key or find the current user's public key public_key = key || user_public_key # Convert the key to an OpenSSL-compatible key compat_public_key = openssl_compat(public_key) # Validate the payload was signed with the private key for the public key if @payload_data && @payload_data != "" JWT.decode(jwt_data, compat_public_key, true, { algorithm: @header_data["alg"] }) else digest = digest_from_alg(@header_data["alg"]) sig = if @header_data["alg"].downcase.start_with?("es") ecdsa_sig_to_der(signature) elsif @header_data["alg"].downcase.start_with?("rs") Base64.urlsafe_decode64(signature) end validated = compat_public_key.verify( digest, sig, "#{@json_body[:protected]}." ) raise Bullion::Acme::Errors::Malformed unless validated end end # rubocop:enable Metrics/AbcSize # rubocop:enable Metrics/MethodLength # rubocop:enable Metrics/PerceivedComplexity # rubocop:enable Metrics/CyclomaticComplexity def extract_header_data JSON.parse(Base64.decode64(@json_body[:protected])) end def extract_payload_data if @json_body[:payload] && @json_body[:payload] != "" JSON.parse(Base64.decode64(@json_body[:payload])) else @json_body[:payload] end end def user_public_key @user = if @header_data["kid"] user_id = @header_data["kid"].split("/").last return unless user_id Models::Account.find(user_id) else Models::Account.where(public_key: @header_data["jwk"]).last end @user.public_key end # Validation helpers def account_data_valid?(hash) unless [true, false, nil].include?(hash["onlyReturnExisting"]) raise Bullion::Acme::Errors::Malformed, "Invalid onlyReturnExisting: #{hash["onlyReturnExisting"]}" end unless hash["contact"].is_a?(Array) raise Bullion::Acme::Errors::InvalidContact, "Invalid contacts format: #{hash["contact"].class}, #{hash}" end unless hash["contact"].size.positive? raise Bullion::Acme::Errors::InvalidContact, "Empty contacts list" end # Contacts must be a valid email # TODO: find a better email verification approach unless hash["contact"].grep_v(/^mailto:[a-zA-Z0-9@.+-]{3,}/).empty? raise Bullion::Acme::Errors::UnsupportedContact end true end def acme_csr_valid?(order_csr) csr = order_csr.csr order = order_csr.order csr_attrs = extract_csr_attrs(csr) csr_sans = extract_csr_sans(csr_attrs) csr_domains = extract_csr_domains(csr_sans) csr_cn = cn_from_csr(csr) # Make sure the CSR has a valid public key raise Bullion::Acme::Errors::BadCsr unless csr.verify(csr.public_key) return false unless order.ready_status? raise Bullion::Acme::Errors::BadCsr unless csr_domains.include?(csr_cn) raise Bullion::Acme::Errors::BadCsr unless csr_domains.sort == order.domains.sort true end def order_valid?(hash) validate_order_nb_and_na(hash["notBefore"], hash["notAfter"]) # Don't approve empty orders raise Bullion::Acme::Errors::InvalidOrder, "Empty order!" if hash["identifiers"].empty? order_domains = hash["identifiers"].select { it["type"] == "dns" } # Don't approve an order with identifiers that _aren't_ of type 'dns' unless hash["identifiers"] == order_domains raise Bullion::Acme::Errors::InvalidOrder, 'Only type "dns" allowed' end # Extract domains that end with something in our allowed domains list valid_domains = extract_valid_order_domains(order_domains) # Only allow configured domains... unless order_domains == valid_domains raise( Bullion::Acme::Errors::InvalidOrder, "Domains #{order_domains - valid_domains} not allowed" ) end true end # rubocop:disable Metrics/AbcSize # rubocop:disable Metrics/CyclomaticComplexity # rubocop:disable Metrics/PerceivedComplexity def validate_order_nb_and_na(not_before, not_after) raise Bullion::Acme::Errors::Malformed if not_before && !not_before.is_a?(String) raise Bullion::Acme::Errors::Malformed if not_after && !not_after.is_a?(String) return unless not_before && not_after nb = Time.parse(not_before) na = Time.parse(not_after) # don't allow nonsense certs raise Bullion::Acme::Errors::InvalidOrder unless nb < na # don't allow far-future certs if nb > Time.now + (7 * 86_400) || na > Time.now + CERT_VALIDITY_DURATION raise Bullion::Acme::Errors::InvalidOrder end # don't allow really "old" certs raise Bullion::Acme::Errors::InvalidOrder if nb < Time.now - (14 * 86_400) # don't allow creating certs that are already expired raise Bullion::Acme::Errors::InvalidOrder if na <= Time.now end # rubocop:enable Metrics/AbcSize # rubocop:enable Metrics/CyclomaticComplexity # rubocop:enable Metrics/PerceivedComplexity def extract_valid_order_domains(order_domains) order_domains.reject do |domain| Bullion.config.ca.domains.none? { domain["value"].end_with?(it) } end end end end end