Commit 24a14e8d authored by Paul Cammish's avatar Paul Cammish

Merge branch 'buster-testing' into 'buster'

Buster testing -> Buster

See merge request sympl/sympl!144
parents 73f14ea5 8936bad9
sympl-core (10.0.191017.0) stable; urgency=medium
* Updated sympl-ssl to use Let's Encrypt ACME v02 API
-- Paul Cammish <sympl@kelduum.net> Thu, 17 Oct 2019 13:45:01 +0100
sympl-core (10.0.190908.0) stable; urgency=medium
* Set default threshold for LE cert renewal to 30 days.
......
This diff is collapsed.
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
......@@ -104,8 +104,6 @@ class Acme::Client::CertificateRequest
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
)
......
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
class ClientError < Acme::Client::Error; end
class InvalidDirectory < ClientError; end
class UnsupportedOperation < ClientError; end
class UnsupportedChallengeType < ClientError; end
class NotFound < ClientError; end
class CertificateNotReady < ClientError; end
class ServerError < Acme::Client::Error; end
class BadCSR < ServerError; end
class BadNonce < ServerError; end
class BadSignatureAlgorithm < ServerError; end
class InvalidContact < ServerError; end
class UnsupportedContact < ServerError; end
class ExternalAccountRequired < ServerError; end
class AccountDoesNotExist < ServerError; end
class Malformed < ServerError; end
class RateLimited < ServerError; end
class RejectedIdentifier < ServerError; end
class ServerInternal < ServerError; end
class Unauthorized < ServerError; end
class UnsupportedIdentifier < ServerError; end
class UserActionRequired < ServerError; end
class BadRevocationReason < ServerError; end
class Caa < ServerError; end
class Dns < ServerError; end
class Connection < ServerError; end
class Tls < ServerError; end
class IncorrectResponse < ServerError; end
ACME_ERRORS = {
'urn:ietf:params:acme:error:badCSR' => BadCSR,
'urn:ietf:params:acme:error:badNonce' => BadNonce,
'urn:ietf:params:acme:error:badSignatureAlgorithm' => BadSignatureAlgorithm,
'urn:ietf:params:acme:error:invalidContact' => InvalidContact,
'urn:ietf:params:acme:error:unsupportedContact' => UnsupportedContact,
'urn:ietf:params:acme:error:externalAccountRequired' => ExternalAccountRequired,
'urn:ietf:params:acme:error:accountDoesNotExist' => AccountDoesNotExist,
'urn:ietf:params:acme:error:malformed' => Malformed,
'urn:ietf:params:acme:error:rateLimited' => RateLimited,
'urn:ietf:params:acme:error:rejectedIdentifier' => RejectedIdentifier,
'urn:ietf:params:acme:error:serverInternal' => ServerInternal,
'urn:ietf:params:acme:error:unauthorized' => Unauthorized,
'urn:ietf:params:acme:error:unsupportedIdentifier' => UnsupportedIdentifier,
'urn:ietf:params:acme:error:userActionRequired' => UserActionRequired,
'urn:ietf:params:acme:error:badRevocationReason' => BadRevocationReason,
'urn:ietf:params:acme:error:caa' => Caa,
'urn:ietf:params:acme:error:dns' => Dns,
'urn:ietf:params:acme:error:connection' => Connection,
'urn:ietf:params:acme:error:tls' => Tls,
'urn:ietf:params:acme:error:incorrectResponse' => IncorrectResponse
}
end
......@@ -3,20 +3,25 @@
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
CONTENT_TYPE = 'application/jose+json'
def initialize(app, client:)
def initialize(app, client:, mode:)
super(app)
@client = client
@mode = mode
end
def call(env)
@env = env
@env[:request_headers]['User-Agent'] = USER_AGENT
@env.body = client.jwk.jws(header: { nonce: pop_nonce }, payload: env.body)
@env[:request_headers]['User-Agent'] = Acme::Client::USER_AGENT
@env[:request_headers]['Content-Type'] = CONTENT_TYPE
if @env.method != :get
@env.body = client.jwk.jws(header: jws_header, payload: env.body)
end
@app.call(env).on_complete { |response_env| on_complete(response_env) }
rescue Faraday::TimeoutError
rescue Faraday::TimeoutError, Faraday::ConnectionFailed
raise Acme::Client::Error::Timeout
end
......@@ -35,6 +40,12 @@ class Acme::Client::FaradayMiddleware < Faraday::Middleware
private
def jws_header
headers = { nonce: pop_nonce, url: env.url.to_s }
headers[:kid] = client.kid if @mode == :kid
headers
end
def raise_on_not_found!
raise Acme::Client::Error::NotFound, env.url.to_s if env.status == 404
end
......@@ -52,30 +63,19 @@ class Acme::Client::FaradayMiddleware < Faraday::Middleware
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
Acme::Client::Error::ACME_ERRORS.fetch(error_name, Acme::Client::Error)
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
return unless env.body.is_a?(Hash)
return unless env.body.key?('type')
env.body['type']
end
def decode_body
content_type = env.response_headers['Content-Type']
content_type = env.response_headers['Content-Type'].to_s
if content_type == 'application/json' || content_type == 'application/problem+json'
if content_type.start_with?('application/json', 'application/problem+json')
JSON.load(env.body)
else
env.body
......@@ -97,20 +97,20 @@ class Acme::Client::FaradayMiddleware < Faraday::Middleware
end
def store_nonce
nonces << env.response_headers['replay-nonce']
nonce = env.response_headers['replay-nonce']
nonces << nonce if nonce
end
def pop_nonce
if nonces.empty?
get_nonce
else
nonces.pop
end
nonces.pop
end
def get_nonce
response = Faraday.head(env.url, nil, 'User-Agent' => USER_AGENT)
response.headers['replay-nonce']
client.get_nonce
end
def nonces
......
......@@ -15,7 +15,7 @@ class Acme::Client::JWK::Base
#
# Returns a JSON String.
def jws(header: {}, payload: {})
header = jws_header.merge(header)
header = jws_header(header)
encoded_header = Acme::Client::Util.urlsafe_base64(header.to_json)
encoded_payload = Acme::Client::Util.urlsafe_base64(payload.to_json)
......@@ -56,12 +56,13 @@ class Acme::Client::JWK::Base
# typ: - Value for the `typ` field. Default 'JWT'.
#
# Returns a Hash.
def jws_header
{
def jws_header(header)
jws = {
typ: 'JWT',
alg: jwa_alg,
jwk: to_h
}
alg: jwa_alg
}.merge(header)
jws[:jwk] = to_h if header[:kid].nil?
jws
end
# The name of the algorithm as needed for the `alg` member of a JWS object.
......
......@@ -82,7 +82,6 @@ class Acme::Client::JWK::ECDSA < Acme::Client::JWK::Base
private
# rubocop:disable Metrics/AbcSize
def coordinates
@coordinates ||= begin
hex = public_key.to_bn.to_s(16)
......@@ -96,7 +95,6 @@ class Acme::Client::JWK::ECDSA < Acme::Client::JWK::Base
}
end
end
# rubocop:enable Metrics/AbcSize
def public_key
@private_key.public_key
......
module Acme::Client::Resources; end
require 'acme/client/resources/registration'
require 'acme/client/resources/challenges'
require 'acme/client/resources/directory'
require 'acme/client/resources/account'
require 'acme/client/resources/order'
require 'acme/client/resources/authorization'
require 'acme/client/resources/challenges'
# frozen_string_literal: true
class Acme::Client::Resources::Account
attr_reader :url, :status, :contact, :term_of_service, :orders_url
def initialize(client, **arguments)
@client = client
assign_attributes(arguments)
end
def kid
url
end
def update(contact: nil, terms_of_service_agreed: nil)
assign_attributes(**@client.account_update(
contact: contact, terms_of_service_agreed: term_of_service
).to_h)
true
end
def deactivate
assign_attributes(**@client.account_deactivate.to_h)
true
end
def reload
assign_attributes(**@client.account.to_h)
true
end
def to_h
{
url: url,
term_of_service: term_of_service,
status: status,
contact: contact
}
end
private
def assign_attributes(url:, term_of_service:, status:, contact:)
@url = url
@term_of_service = term_of_service
@status = status
@contact = Array(contact)
end
end
class Acme::Client::Resources::Authorization
HTTP01 = Acme::Client::Resources::Challenges::HTTP01
DNS01 = Acme::Client::Resources::Challenges::DNS01
TLSSNI01 = Acme::Client::Resources::Challenges::TLSSNI01
# frozen_string_literal: true
attr_reader :client, :uri, :domain, :status, :expires, :http01, :dns01, :tls_sni01
class Acme::Client::Resources::Authorization
attr_reader :url, :identifier, :domain, :expires, :status, :wildcard
def initialize(client, uri, response)
def initialize(client, **arguments)
@client = client
@uri = uri
assign_attributes(response.body)
assign_attributes(arguments)
end
def deactivate
assign_attributes(**@client.deactivate_authorization(url: url).to_h)
true
end
def verify_status
response = @client.connection.get(@uri)
def reload
assign_attributes(**@client.authorization(url: url).to_h)
true
end
def challenges
@challenges.map do |challenge|
initialize_challenge(challenge)
end
end
assign_attributes(response.body)
status
def http01
@http01 ||= challenges.find { |challenge|
challenge.is_a?(Acme::Client::Resources::Challenges::HTTP01)
}
end
alias_method :http, :http01
def dns01
@dns01 ||= challenges.find { |challenge|
challenge.is_a?(Acme::Client::Resources::Challenges::DNS01)
}
end
alias_method :dns, :dns01
def to_h
{
url: url,
identifier: identifier,
status: status,
expires: expires,
challenges: @challenges,
wildcard: wildcard
}
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
def initialize_challenge(attributes)
arguments = {
type: attributes.fetch('type'),
status: attributes.fetch('status'),
url: attributes.fetch('url'),
token: attributes.fetch('token'),
error: attributes['error']
}
Acme::Client::Resources::Challenges.new(@client, **arguments)
end
def assign_attributes(url:, status:, expires:, challenges:, identifier:, wildcard: false)
@url = url
@identifier = identifier
@domain = identifier.fetch('value')
@status = status
@expires = expires
@challenges = challenges
@wildcard = wildcard
end
end
module Acme::Client::Resources::Challenges; end
# frozen_string_literal: true
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'
module Acme::Client::Resources::Challenges
require 'acme/client/resources/challenges/base'
require 'acme/client/resources/challenges/http01'
require 'acme/client/resources/challenges/dns01'
CHALLENGE_TYPES = {
'http-01' => Acme::Client::Resources::Challenges::HTTP01,
'dns-01' => Acme::Client::Resources::Challenges::DNS01
}
def self.new(client, type:, **arguments)
klass = CHALLENGE_TYPES[type]
if klass
klass.new(client, **arguments)
else
{ type: type }.merge(arguments)
end
end
end
# frozen_string_literal: true
class Acme::Client::Resources::Challenges::Base
attr_reader :authorization, :status, :uri, :token, :error
attr_reader :status, :url, :token, :error
def initialize(authorization)
@authorization = authorization
def initialize(client, **arguments)
@client = client
assign_attributes(arguments)
end
def client
authorization.client
def challenge_type
self.class::CHALLENGE_TYPE
end
def verify_status
authorization.verify_status
status
def key_authorization
"#{token}.#{@client.jwk.thumbprint}"
end
def request_verification
response = client.connection.post(@uri, resource: 'challenge', type: challenge_type, keyAuthorization: authorization_key)
response.success?
def reload
assign_attributes(**@client.challenge(url: url).to_h)
true
end
def assign_attributes(attributes)
@status = attributes.fetch('status', 'pending')
@uri = attributes.fetch('uri')
@token = attributes.fetch('token')
@error = attributes['error']
def send_challenge_vallidation(url:, key_authorization:)
@client.request_challenge_validation(
url: url,
key_authorization: key_authorization
).to_h
end
private
def request_validation
assign_attributes(**send_challenge_vallidation(
url: url,
key_authorization: key_authorization
))
true
end
def challenge_type
self.class::CHALLENGE_TYPE
def to_h
{ status: status, url: url, token: token, error: error }
end
def authorization_key
"#{token}.#{client.jwk.thumbprint}"
private
def assign_attributes(status:, url:, token:, error: nil)
@status = status
@url = url
@token = token
@error = error
end
end
......@@ -15,6 +15,6 @@ class Acme::Client::Resources::Challenges::DNS01 < Acme::Client::Resources::Chal
end
def record_content
Acme::Client::Util.urlsafe_base64(DIGEST.digest(authorization_key))
Acme::Client::Util.urlsafe_base64(DIGEST.digest(key_authorization))
end
end
......@@ -9,7 +9,7 @@ class Acme::Client::Resources::Challenges::HTTP01 < Acme::Client::Resources::Cha
end
def file_content
authorization_key
key_authorization
end
def filename
......
# frozen_string_literal: true
class Acme::Client::Resources::Challenges::TLSSNI01 < Acme::Client::Resources::Challenges::Base
CHALLENGE_TYPE = 'tls-sni-01'.freeze
DIGEST = OpenSSL::Digest::SHA256
def hostname
digest = 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
# frozen_string_literal: true
class Acme::Client::Resources::Directory
DIRECTORY_RESOURCES = {
new_nonce: 'newNonce',
new_account: 'newAccount',
new_order: 'newOrder',
new_authz: 'newAuthz',
revoke_certificate: 'revokeCert',
key_change: 'keyChange'
}
DIRECTORY_META = {
terms_of_service: 'termsOfService',
website: 'website',
caa_identities: 'caaIdentities',
external_account_required: 'externalAccountRequired'
}
def initialize(url, connection_options)
@url, @connection_options = url, connection_options
end
def endpoint_for(key)
directory.fetch(key) do |missing_key|
raise Acme::Client::Error::UnsupportedOperation,
"Directory at #{@url} does not include `#{missing_key}`"
end
end
def terms_of_service
meta[DIRECTORY_META[:terms_of_service]]
end
def website
meta[DIRECTORY_META[:website]]
end
def caa_identities
meta[DIRECTORY_META[:caa_identities]]
end
def external_account_required
meta[DIRECTORY_META[:external_account_required]]
end
def meta
directory[:meta]
end
private
def directory
@directory ||= load_directory
end
def load_directory
body = fetch_directory
result = {}
result[:meta] = body.delete('meta')
DIRECTORY_RESOURCES.each do |key, entry|
result[key] = URI(body[entry]) if body[entry]
end
result
rescue JSON::ParserError => exception
raise Acme::Client::Error::InvalidDirectory,
"Invalid directory url\n#{@directory} did not return a valid directory\n#{exception.inspect}"
end
def fetch_directory
connection = Faraday.new(url: @directory, **@connection_options)
connection.headers[:user_agent] = Acme::Client::USER_AGENT
response = connection.get(@url)
JSON.parse(response.body)
end
end
# frozen_string_literal: true
class Acme::Client::Resources::Order
attr_reader :url, :status, :contact, :finalize_url, :identifiers, :authorization_urls, :expires, :certificate_url
def initialize(client, **arguments)
@client = client
assign_attributes(arguments)
end
def reload
assign_attributes(**@client.order(url: url).to_h)
true
end
def authorizations
@authorization_urls.map do |authorization_url|
@client.authorization(url: authorization_url)
end
end
def finalize(csr:)
assign_attributes(**@client.finalize(url: finalize_url, csr: csr).to_h)
true
end
def certificate
if certificate_url
@client.certificate(url: certificate_url)
else
raise Acme::Client::Error::CertificateNotReady, 'No certificate_url to collect the order'
end
end
def to_h
{
url: url,
status: status,
expires: expires,
finalize_url: finalize_url,
authorization_urls: authorization_urls,
identifiers: identifiers,
certificate_url: certificate_url
}
end
private