Commit ab2048b7 authored by Paul Cammish's avatar Paul Cammish
Browse files

Replace folded-in version of ruby acme-client with ~3 year old version

parent fa16a1da
No preview for this file type
module Acme; class Client; end; end
require 'faraday'
require 'json'
require 'json/jwt'
require 'openssl'
require 'digest'
require 'forwardable'
require 'acme/client/certificate'
require 'acme/client/certificate_request'
require 'acme/client/self_sign_certificate'
require 'acme/client/crypto'
require 'acme/client' require 'acme/client'
require 'acme/client/resources'
require 'acme/client/faraday_middleware'
require 'acme/client/error'
require 'acme-client' # 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/resources'
require 'acme/client/faraday_middleware'
require 'acme/client/jwk'
require 'acme/client/error'
require 'acme/client/util'
class Acme::Client class Acme::Client
DEFAULT_ENDPOINT = 'http://127.0.0.1:4000'.freeze DEFAULT_ENDPOINT = 'http://127.0.0.1:4000'.freeze
...@@ -9,13 +30,23 @@ class Acme::Client ...@@ -9,13 +30,23 @@ class Acme::Client
'revoke-cert' => '/acme/revoke-cert' 'revoke-cert' => '/acme/revoke-cert'
}.freeze }.freeze
def initialize(private_key:, endpoint: DEFAULT_ENDPOINT, directory_uri: nil) def initialize(jwk: nil, private_key: nil, endpoint: DEFAULT_ENDPOINT, directory_uri: nil, connection_options: {})
@endpoint, @private_key, @directory_uri = endpoint, private_key, directory_uri 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
@endpoint, @directory_uri, @connection_options = endpoint, directory_uri, connection_options
@nonces ||= [] @nonces ||= []
load_directory! load_directory!
end end
attr_reader :private_key, :nonces, :operation_endpoints attr_reader :jwk, :nonces, :endpoint, :directory_uri, :operation_endpoints
def register(contact:) def register(contact:)
payload = { payload = {
...@@ -36,7 +67,12 @@ class Acme::Client ...@@ -36,7 +67,12 @@ class Acme::Client
} }
response = connection.post(@operation_endpoints.fetch('new-authz'), payload) response = connection.post(@operation_endpoints.fetch('new-authz'), payload)
::Acme::Client::Resources::Authorization.new(self, response) ::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 end
def new_certificate(csr) def new_certificate(csr)
...@@ -46,7 +82,7 @@ class Acme::Client ...@@ -46,7 +82,7 @@ class Acme::Client
} }
response = connection.post(@operation_endpoints.fetch('new-cert'), payload) response = connection.post(@operation_endpoints.fetch('new-cert'), payload)
::Acme::Client::Certificate.new(OpenSSL::X509::Certificate.new(response.body), fetch_chain(response), csr) ::Acme::Client::Certificate.new(OpenSSL::X509::Certificate.new(response.body), response.headers['location'], fetch_chain(response), csr)
end end
def revoke_certificate(certificate) def revoke_certificate(certificate)
...@@ -62,23 +98,12 @@ class Acme::Client ...@@ -62,23 +98,12 @@ class Acme::Client
end end
def connection def connection
@connection ||= Faraday.new(@endpoint) do |configuration| @connection ||= Faraday.new(@endpoint, **@connection_options) do |configuration|
configuration.use Acme::Client::FaradayMiddleware, client: self configuration.use Acme::Client::FaradayMiddleware, client: self
configuration.adapter Faraday.default_adapter configuration.adapter Faraday.default_adapter
end end
end end
def challenge_from_hash(attributes)
case attributes.fetch('type')
when 'http-01'
Acme::Client::Resources::Challenges::HTTP01.new(self, attributes)
when 'dns-01'
Acme::Client::Resources::Challenges::DNS01.new(self, attributes)
when 'tls-sni-01'
Acme::Client::Resources::Challenges::TLSSNI01.new(self, attributes)
end
end
private private
def fetch_chain(response, limit = 10) def fetch_chain(response, limit = 10)
......
class Acme::Client::Certificate class Acme::Client::Certificate
extend Forwardable extend Forwardable
attr_reader :x509, :x509_chain, :request, :private_key attr_reader :x509, :x509_chain, :request, :private_key, :url
def_delegators :x509, :to_pem, :to_der def_delegators :x509, :to_pem, :to_der
def initialize(certificate, chain, request) def initialize(certificate, url, chain, request)
@x509 = certificate @x509 = certificate
@url = url
@x509_chain = chain @x509_chain = chain
@request = request @request = request
end end
......
...@@ -79,8 +79,17 @@ class Acme::Client::CertificateRequest ...@@ -79,8 +79,17 @@ class Acme::Client::CertificateRequest
def generate def generate
OpenSSL::X509::Request.new.tap do |csr| OpenSSL::X509::Request.new.tap do |csr|
csr.public_key = @private_key.public_key if @private_key.is_a?(OpenSSL::PKey::EC) && RbConfig::CONFIG['MAJOR'] == '2' &&
RbConfig::CONFIG['MINOR'].to_i < 4
# OpenSSL::PKey::EC does not respect classic PKey interface (as defined by
# PKey::RSA and PKey::DSA) until ruby 2.4.
# Supporting this interface needs monkey patching of OpenSSL:PKey::EC, or
# subclassing it. Here, use a subclass.
@private_key = ECKeyPatch.new(@private_key)
end
csr.public_key = @private_key
csr.subject = generate_subject csr.subject = generate_subject
csr.version = 2
add_extension(csr) add_extension(csr)
csr.sign @private_key, @digest csr.sign @private_key, @digest
end end
...@@ -108,3 +117,5 @@ class Acme::Client::CertificateRequest ...@@ -108,3 +117,5 @@ class Acme::Client::CertificateRequest
) )
end end
end end
require 'acme/client/certificate_request/ec_key_patch'
# Class to handle bug #
class Acme::Client::CertificateRequest::ECKeyPatch < OpenSSL::PKey::EC
alias private? private_key?
alias public? public_key?
end
class Acme::Client::Crypto
attr_reader :private_key
def initialize(private_key)
@private_key = private_key
end
def generate_signed_jws(header:, payload:)
jwt = JSON::JWT.new(payload || {})
jwt.header.merge!(header || {})
jwt.header[:jwk] = jwk
jwt.signature = jwt.sign(private_key, :RS256).signature
jwt.to_json(syntax: :flattened)
end
def thumbprint
jwk.thumbprint
end
def digest
OpenSSL::Digest::SHA256.new
end
private
def jwk
@jwk ||= JSON::JWK.new(public_key)
end
def public_key
@public_key ||= private_key.public_key
end
end
...@@ -9,4 +9,8 @@ class Acme::Client::Error < StandardError ...@@ -9,4 +9,8 @@ class Acme::Client::Error < StandardError
class Acme::Tls < Acme::Client::Error; end class Acme::Tls < Acme::Client::Error; end
class Unauthorized < Acme::Client::Error; end class Unauthorized < Acme::Client::Error; end
class UnknownHost < 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 end
# frozen_string_literal: true
class Acme::Client::FaradayMiddleware < Faraday::Middleware class Acme::Client::FaradayMiddleware < Faraday::Middleware
attr_reader :env, :response, :client 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:) def initialize(app, client:)
super(app) super(app)
@client = client @client = client
...@@ -8,11 +13,11 @@ class Acme::Client::FaradayMiddleware < Faraday::Middleware ...@@ -8,11 +13,11 @@ class Acme::Client::FaradayMiddleware < Faraday::Middleware
def call(env) def call(env)
@env = env @env = env
unless @env.body.nil? @env[:request_headers]['User-Agent'] = USER_AGENT
@env.body = crypto.generate_signed_jws(header: { nonce: pop_nonce }, payload: env.body) @env.body = client.jwk.jws(header: { nonce: pop_nonce }, payload: env.body)
@env.request_headers['Content-Type'] ||= 'application/json'
end
@app.call(env).on_complete { |response_env| on_complete(response_env) } @app.call(env).on_complete { |response_env| on_complete(response_env) }
rescue Faraday::TimeoutError
raise Acme::Client::Error::Timeout
end end
def on_complete(env) def on_complete(env)
...@@ -47,14 +52,26 @@ class Acme::Client::FaradayMiddleware < Faraday::Middleware ...@@ -47,14 +52,26 @@ class Acme::Client::FaradayMiddleware < Faraday::Middleware
end end
def error_class def error_class
error_name = env.body['type'].gsub('urn:acme:error:', '').classify if error_name && !error_name.empty? && Acme::Client::Error.const_defined?(error_name)
if Acme::Client::Error.qualified_const_defined?(error_name) Object.const_get("Acme::Client::Error::#{error_name}")
"Acme::Client::Error::#{error_name}".constantize
else else
Acme::Client::Error Acme::Client::Error
end end
end end
def error_name
@error_name ||= begin
return unless env.body.is_a?(Hash)
return unless env.body.key?('type')
error_type_to_klass env.body['type']
end
end
def error_type_to_klass(type)
type.gsub('urn:acme:error:', '').split(/[_-]/).map { |type_part| type_part[0].upcase + type_part[1..-1] }.join
end
def decode_body def decode_body
content_type = env.response_headers['Content-Type'] content_type = env.response_headers['Content-Type']
...@@ -92,19 +109,11 @@ class Acme::Client::FaradayMiddleware < Faraday::Middleware ...@@ -92,19 +109,11 @@ class Acme::Client::FaradayMiddleware < Faraday::Middleware
end end
def get_nonce def get_nonce
response = Faraday.head(env.url) response = Faraday.head(env.url, nil, 'User-Agent' => USER_AGENT)
response.headers['replay-nonce'] response.headers['replay-nonce']
end end
def nonces def nonces
client.nonces client.nonces
end end
def private_key
client.private_key
end
def crypto
@crypto ||= Acme::Client::Crypto.new(private_key)
end
end end
module Acme::Client::JWK
# Make a JWK from a private key.
#
# private_key - An OpenSSL::PKey::EC or OpenSSL::PKey::RSA instance.
#
# Returns a JWK::Base subclass instance.
def self.from_private_key(private_key)
case private_key
when OpenSSL::PKey::RSA
Acme::Client::JWK::RSA.new(private_key)
when OpenSSL::PKey::EC
Acme::Client::JWK::ECDSA.new(private_key)
else
raise ArgumentError, 'private_key must be EC or RSA'
end
end
end
require 'acme/client/jwk/base'
require 'acme/client/jwk/rsa'
require 'acme/client/jwk/ecdsa'
class Acme::Client::JWK::Base
THUMBPRINT_DIGEST = OpenSSL::Digest::SHA256
# Initialize a new JWK.
#
# Returns nothing.
def initialize
raise NotImplementedError
end
# Generate a JWS JSON web signature.
#
# header - A Hash of extra header fields to include.
# payload - A Hash of payload data.
#
# Returns a JSON String.
def jws(header: {}, payload: {})
header = jws_header.merge(header)
encoded_header = Acme::Client::Util.urlsafe_base64(header.to_json)
encoded_payload = Acme::Client::Util.urlsafe_base64(payload.to_json)
signature_data = "#{encoded_header}.#{encoded_payload}"
signature = sign(signature_data)
encoded_signature = Acme::Client::Util.urlsafe_base64(signature)
{
protected: encoded_header,
payload: encoded_payload,
signature: encoded_signature
}.to_json
end
# Serialize this JWK as JSON.
#
# Returns a JSON string.
def to_json
to_h.to_json
end
# Get this JWK as a Hash for JSON serialization.
#
# Returns a Hash.
def to_h
raise NotImplementedError
end
# JWK thumbprint as used for key authorization.
#
# Returns a String.
def thumbprint
Acme::Client::Util.urlsafe_base64(THUMBPRINT_DIGEST.digest(to_json))
end
# Header fields for a JSON web signature.
#
# typ: - Value for the `typ` field. Default 'JWT'.
#
# Returns a Hash.
def jws_header
{
typ: 'JWT',
alg: jwa_alg,
jwk: to_h
}
end
# The name of the algorithm as needed for the `alg` member of a JWS object.
#
# Returns a String.
def jwa_alg
raise NotImplementedError
end
# Sign a message with the private key.
#
# message - A String message to sign.
#
# Returns a String signature.
# rubocop:disable Lint/UnusedMethodArgument
def sign(message)
raise NotImplementedError
end
# rubocop:enable Lint/UnusedMethodArgument
end
class Acme::Client::JWK::ECDSA < Acme::Client::JWK::Base
# JWA parameters for supported OpenSSL curves.
# https://tools.ietf.org/html/rfc7518#section-3.1
KNOWN_CURVES = {
'prime256v1' => {
jwa_crv: 'P-256',
jwa_alg: 'ES256',
digest: OpenSSL::Digest::SHA256
}.freeze,
'secp384r1' => {
jwa_crv: 'P-384',
jwa_alg: 'ES384',
digest: OpenSSL::Digest::SHA384
}.freeze,
'secp521r1' => {
jwa_crv: 'P-521',
jwa_alg: 'ES512',
digest: OpenSSL::Digest::SHA512
}.freeze
}.freeze
# Instantiate a new ECDSA JWK.
#
# private_key - A OpenSSL::PKey::EC instance.
#
# Returns nothing.
def initialize(private_key)
unless private_key.is_a?(OpenSSL::PKey::EC)
raise ArgumentError, 'private_key must be a OpenSSL::PKey::EC'
end
unless @curve_params = KNOWN_CURVES[private_key.group.curve_name]
raise ArgumentError, 'Unknown EC curve'
end
@private_key = private_key
end
# The name of the algorithm as needed for the `alg` member of a JWS object.
#
# Returns a String.
def jwa_alg
@curve_params[:jwa_alg]
end
# Get this JWK as a Hash for JSON serialization.
#
# Returns a Hash.
def to_h
{
crv: @curve_params[:jwa_crv],
kty: 'EC',
x: Acme::Client::Util.urlsafe_base64(coordinates[:x].to_s(2)),
y: Acme::Client::Util.urlsafe_base64(coordinates[:y].to_s(2))
}
end
# Sign a message with the private key.
#
# message - A String message to sign.
#
# Returns a String signature.
def sign(message)
# DER encoded ASN.1 signature
der = @private_key.sign(@curve_params[:digest].new, message)
# ASN.1 SEQUENCE
seq = OpenSSL::ASN1.decode(der)
# ASN.1 INTs
ints = seq.value
# BigNumbers
bns = ints.map(&:value)
# Binary R/S values
r, s = bns.map { |bn| [bn.to_s(16)].pack('H*') }
# JWS wants raw R/S concatenated.
[r, s].join
end
private
# 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
@private_key.public_key
end
end
class Acme::Client::JWK::RSA < Acme::Client::JWK::Base
# Digest algorithm to use when signing.
DIGEST = OpenSSL::Digest::SHA256
# Instantiate a new RSA JWK.
#
# private_key - A OpenSSL::PKey::RSA instance.
#
# Returns nothing.
def initialize(private_key)
unless private_key.is_a?(OpenSSL::PKey::RSA)
raise ArgumentError, 'private_key must be a OpenSSL::PKey::RSA'
end
@private_key = private_key
end
# Get this JWK as a Hash for JSON serialization.
#
# Returns a Hash.
def to_h
{
e: Acme::Client::Util.urlsafe_base64(public_key.e.to_s(2)),
kty: 'RSA',
n: Acme::Client::Util.urlsafe_base64(public_key.n.to_s(2))
}
end
# Sign a message with the private key.
#
# message - A String message to sign.
#
# Returns a String signature.
def sign(message)
@private_key.sign(DIGEST.new, message)
end
# The name of the algorithm as needed for the `alg` member of a JWS object.
#