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.
......
......@@ -7,12 +7,12 @@ 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'
require 'acme/client/certificate_request'
require 'acme/client/self_sign_certificate'
require 'acme/client/resources'
......@@ -22,15 +22,14 @@ require 'acme/client/error'
require 'acme/client/util'
class Acme::Client
DEFAULT_ENDPOINT = 'http://127.0.0.1:4000'.freeze
DIRECTORY_DEFAULT = {
'new-authz' => '/acme/new-authz',
'new-cert' => '/acme/new-cert',
'new-reg' => '/acme/new-reg',
'revoke-cert' => '/acme/revoke-cert'
}.freeze
def initialize(jwk: nil, private_key: nil, endpoint: DEFAULT_ENDPOINT, directory_uri: nil, connection_options: {})
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
......@@ -41,93 +40,269 @@ class Acme::Client
Acme::Client::JWK.from_private_key(private_key)
end
@endpoint, @directory_uri, @connection_options = endpoint, directory_uri, connection_options
@kid, @connection_options = kid, connection_options
@bad_nonce_retry = bad_nonce_retry
@directory = Acme::Client::Resources::Directory.new(URI(directory), @connection_options)
@nonces ||= []
load_directory!
end
attr_reader :jwk, :nonces, :endpoint, :directory_uri, :operation_endpoints
attr_reader :jwk, :nonces
def register(contact:)
def new_account(contact:, terms_of_service_agreed: nil)
payload = {
resource: 'new-reg', contact: Array(contact)
contact: Array(contact)
}
response = connection.post(@operation_endpoints.fetch('new-reg'), payload)
::Acme::Client::Resources::Registration.new(self, response)
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 authorize(domain:)
payload = {
resource: 'new-authz',
identifier: {
type: 'dns',
value: domain
}
}
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 = connection.post(@operation_endpoints.fetch('new-authz'), payload)
::Acme::Client::Resources::Authorization.new(self, response.headers['Location'], response)
response = post(kid, payload: payload)
arguments = attributes_from_account_response(response)
Acme::Client::Resources::Account.new(self, url: kid, **arguments)
end
def fetch_authorization(uri)
response = connection.get(uri)
::Acme::Client::Resources::Authorization.new(self, uri, response)
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 new_certificate(csr)
payload = {
resource: 'new-cert',
csr: Base64.urlsafe_encode64(csr.to_der)
}
def account
@kid ||= begin
response = post(endpoint_for(:new_account), payload: { onlyReturnExisting: true }, mode: :jwk)
response.headers.fetch(:location)
end
response = connection.post(@operation_endpoints.fetch('new-cert'), payload)
::Acme::Client::Certificate.new(OpenSSL::X509::Certificate.new(response.body), response.headers['location'], fetch_chain(response), csr)
response = post(@kid)
arguments = attributes_from_account_response(response)
Acme::Client::Resources::Account.new(self, url: @kid, **arguments)
end
def revoke_certificate(certificate)
payload = { resource: 'revoke-cert', certificate: Base64.urlsafe_encode64(certificate.to_der) }
endpoint = @operation_endpoints.fetch('revoke-cert')
response = connection.post(endpoint, payload)
response.success?
def kid
@kid ||= account.kid
end
def new_order(identifiers:, not_before: nil, not_after: nil)
payload = {}
payload['identifiers'] = if identifiers.is_a?(Hash)
identifiers
else
Array(identifiers).map do |identifier|
{ type: 'dns', value: identifier }
end
end
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 self.revoke_certificate(certificate, *arguments)
client = new(*arguments)
client.revoke_certificate(certificate)
def order(url:)
response = get(url)
arguments = attributes_from_order_response(response)
Acme::Client::Resources::Order.new(self, **arguments.merge(url: url))
end
def connection
@connection ||= Faraday.new(@endpoint, **@connection_options) do |configuration|
configuration.use Acme::Client::FaradayMiddleware, client: self
configuration.adapter Faraday.default_adapter
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:)
response = download(url, format: :pem)
response.body
end
def authorization(url:)
response = 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 = get(url)
arguments = attributes_from_challenge_response(response)
Acme::Client::Resources::Challenges.new(self, **arguments)
end
def request_challenge_validation(url:, key_authorization:)
response = post(url, payload: { keyAuthorization: key_authorization })
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 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 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: :download)
connection.get 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 = connection.get(links['up'])
issuer = get(links['up'])
[OpenSSL::X509::Certificate.new(issuer.body), *fetch_chain(issuer, limit - 1)]
end
end
def load_directory!
@operation_endpoints = if @directory_uri
response = connection.get(@directory_uri)
body = response.body
{
'new-reg' => body.fetch('new-reg'),
'new-authz' => body.fetch('new-authz'),
'new-cert' => body.fetch('new-cert'),
'revoke-cert' => body.fetch('revoke-cert'),
}
else
DIRECTORY_DEFAULT
end
def endpoint_for(key)
@directory.endpoint_for(key)
end
end
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