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

Folded in ruby-acme-client from https://github.com/BytemarkHosting/acme-client

parent ba9c1815
......@@ -10,7 +10,7 @@ XS-Ruby-Versions: all
Package: symbiosis-common
Architecture: all
XB-Ruby-Versions: ${ruby:Versions}
Depends: ruby | ruby-interpreter, ruby-acme-client (>= 0.3.5), ruby-password, ruby-diffy, ruby-erubis, ruby-mocha, ruby-webmock, ruby-test-unit, gnutls-bin, openssl, sudo, adduser, ssl-cert, ${misc:Depends}
Depends: ruby | ruby-interpreter, ruby-password, ruby-diffy, ruby-erubis, ruby-mocha, ruby-webmock, ruby-test-unit, gnutls-bin, openssl, sudo, adduser, ssl-cert, ${misc:Depends}
Replaces: symbiosis-firewall (<< 2011:1214), symbiosis-range, symbiosis-test, bytemark-vhost-range, bytemark-vhost-test, symbiosis-crack
Breaks: symbiosis-firewall (<< 2011:1214), symbiosis-email (<< 2012:0215)
Conflicts: symbiosis-range, symbiosis-test, symbiosis-crack, bytemark-vhost-range, bytemark-vhost-test, symbiosis-email (<< 2012:0215)
......
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/resources'
require 'acme/client/faraday_middleware'
require 'acme/client/error'
require 'acme-client'
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(private_key:, endpoint: DEFAULT_ENDPOINT, directory_uri: nil)
@endpoint, @private_key, @directory_uri = endpoint, private_key, directory_uri
@nonces ||= []
load_directory!
end
attr_reader :private_key, :nonces, :operation_endpoints
def register(contact:)
payload = {
resource: 'new-reg', contact: Array(contact)
}
response = connection.post(@operation_endpoints.fetch('new-reg'), payload)
::Acme::Client::Resources::Registration.new(self, response)
end
def authorize(domain:)
payload = {
resource: 'new-authz',
identifier: {
type: 'dns',
value: domain
}
}
response = connection.post(@operation_endpoints.fetch('new-authz'), payload)
::Acme::Client::Resources::Authorization.new(self, response)
end
def new_certificate(csr)
payload = {
resource: 'new-cert',
csr: Base64.urlsafe_encode64(csr.to_der)
}
response = connection.post(@operation_endpoints.fetch('new-cert'), payload)
::Acme::Client::Certificate.new(OpenSSL::X509::Certificate.new(response.body), fetch_chain(response), csr)
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?
end
def self.revoke_certificate(certificate, *arguments)
client = new(*arguments)
client.revoke_certificate(certificate)
end
def connection
@connection ||= Faraday.new(@endpoint) do |configuration|
configuration.use Acme::Client::FaradayMiddleware, client: self
configuration.adapter Faraday.default_adapter
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
def fetch_chain(response, limit = 10)
links = response.headers['link']
if limit.zero? || links.nil? || links['up'].nil?
[]
else
issuer = connection.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
end
end
class Acme::Client::Certificate
extend Forwardable
attr_reader :x509, :x509_chain, :request, :private_key
def_delegators :x509, :to_pem, :to_der
def initialize(certificate, chain, request)
@x509 = certificate
@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
class Acme::Client::CertificateRequest
extend Forwardable
DEFAULT_KEY_LENGTH = 2048
DEFAULT_DIGEST = OpenSSL::Digest::SHA256
SUBJECT_KEYS = {
common_name: 'CN',
country_name: 'C',
organization_name: 'O',
organizational_unit: 'OU',
state_or_province: 'ST',
locality_name: 'L'
}.freeze
SUBJECT_TYPES = {
'CN' => OpenSSL::ASN1::UTF8STRING,
'C' => OpenSSL::ASN1::UTF8STRING,
'O' => OpenSSL::ASN1::UTF8STRING,
'OU' => OpenSSL::ASN1::UTF8STRING,
'ST' => OpenSSL::ASN1::UTF8STRING,
'L' => OpenSSL::ASN1::UTF8STRING
}.freeze
attr_reader :private_key, :common_name, :names, :subject
def_delegators :csr, :to_pem, :to_der
def initialize(common_name: nil, names: [], private_key: generate_private_key, subject: {}, digest: DEFAULT_DIGEST.new)
@digest = digest
@private_key = private_key
@subject = normalize_subject(subject)
@common_name = common_name || @subject[SUBJECT_KEYS[:common_name]] || @subject[:common_name]
@names = names.to_a.dup
normalize_names
@subject[SUBJECT_KEYS[:common_name]] ||= @common_name
validate_subject
end
def csr
@csr ||= generate
end
private
def generate_private_key
OpenSSL::PKey::RSA.new(DEFAULT_KEY_LENGTH)
end
def normalize_subject(subject)
@subject = subject.each_with_object({}) do |(key, value), hash|
hash[SUBJECT_KEYS.fetch(key, key)] = value.to_s
end
end
def normalize_names
if @common_name
@names.unshift(@common_name) unless @names.include?(@common_name)
else
raise ArgumentError, 'No common name and no list of names given' if @names.empty?
@common_name = @names.first
end
end
def validate_subject
validate_subject_attributes
validate_subject_common_name
end
def validate_subject_attributes
extra_keys = @subject.keys - SUBJECT_KEYS.keys - SUBJECT_KEYS.values
return if extra_keys.empty?
raise ArgumentError, "Unexpected subject attributes given: #{extra_keys.inspect}"
end
def validate_subject_common_name
return if @common_name == @subject[SUBJECT_KEYS[:common_name]]
raise ArgumentError, 'Conflicting common name given in arguments and subject'
end
def generate
OpenSSL::X509::Request.new.tap do |csr|
csr.public_key = @private_key.public_key
csr.subject = generate_subject
add_extension(csr)
csr.sign @private_key, @digest
end
end
def generate_subject
OpenSSL::X509::Name.new(
@subject.map {|name, value|
[name, value, SUBJECT_TYPES[name]]
}
)
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
)
csr.add_attribute(
OpenSSL::X509::Attribute.new(
'extReq',
OpenSSL::ASN1::Set.new([OpenSSL::ASN1::Sequence.new([extension])])
)
)
end
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
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
end
class Acme::Client::FaradayMiddleware < Faraday::Middleware
attr_reader :env, :response, :client
def initialize(app, client:)
super(app)
@client = client
end
def call(env)
@env = env
unless @env.body.nil?
@env.body = crypto.generate_signed_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) }
end
def on_complete(env)
@env = env
raise_on_not_found!
store_nonce
env.body = decode_body
env.response_headers['Link'] = decode_link_headers
return if env.success?
raise_on_error!
end
private
def raise_on_not_found!
raise Acme::Client::Error::NotFound, env.url.to_s if env.status == 404
end
def raise_on_error!
raise error_class, error_message
end
def error_message
if env.body.is_a? Hash
env.body['detail']
else
"Error message: #{env.body}"
end
end
def error_class
error_name = env.body['type'].gsub('urn:acme:error:', '').classify
if Acme::Client::Error.qualified_const_defined?(error_name)
"Acme::Client::Error::#{error_name}".constantize
else
Acme::Client::Error
end
end
def decode_body
content_type = env.response_headers['Content-Type']
if content_type == 'application/json' || content_type == 'application/problem+json'
JSON.load(env.body)
else
env.body
end
end
LINK_MATCH = /<(.*?)>;rel="([\w-]+)"/
def decode_link_headers
return unless env.response_headers.key?('Link')
link_header = env.response_headers['Link']
links = link_header.split(', ').map { |entry|
_, link, name = *entry.match(LINK_MATCH)
[name, link]
}
Hash[*links.flatten]
end
def store_nonce
nonces << env.response_headers['replay-nonce']
end
def pop_nonce
if nonces.empty?
get_nonce
else
nonces.pop
end
end
def get_nonce
response = Faraday.head(env.url)
response.headers['replay-nonce']
end
def nonces
client.nonces
end
def private_key
client.private_key
end
def crypto
@crypto ||= Acme::Client::Crypto.new(private_key)
end
end
module Acme::Client::Resources; end
require 'acme/client/resources/registration'
require 'acme/client/resources/challenges'
require 'acme/client/resources/authorization'
class Acme::Client::Resources::Authorization
HTTP01 = Acme::Client::Resources::Challenges::HTTP01
DNS01 = Acme::Client::Resources::Challenges::DNS01
TLSSNI01 = Acme::Client::Resources::Challenges::TLSSNI01
attr_reader :domain, :status, :expires, :http01, :dns01, :tls_sni01
def initialize(client, response)
@client = client
assign_challenges(response.body['challenges'])
assign_attributes(response.body)
end
private
def assign_challenges(challenges)
challenges.each do |attributes|
case attributes.fetch('type')
when 'http-01' then @http01 = HTTP01.new(@client, attributes)
when 'dns-01' then @dns01 = DNS01.new(@client, attributes)
when 'tls-sni-01' then @tls_sni01 = TLSSNI01.new(@client, attributes)
# else no-op
end
end
end
def assign_attributes(body)
@expires = Time.iso8601(body['expires']) if body.key? 'expires'
@domain = body['identifier']['value']
@status = body['status']
end
end
module Acme::Client::Resources::Challenges; end
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'
class Acme::Client::Resources::Challenges::Base
attr_reader :client, :status, :uri, :token, :error
def initialize(client, attributes)
@client = client
assign_attributes(attributes)
end
def verify_status
response = @client.connection.get(@uri)
assign_attributes(response.body)
@error = response.body['error']
status
end
def request_verification
response = @client.connection.post(@uri, resource: 'challenge', type: challenge_type, keyAuthorization: authorization_key)
response.success?
end
def to_h
{ 'token' => token, 'uri' => uri, 'type' => challenge_type }
end
private
def challenge_type
self.class::CHALLENGE_TYPE
end
def authorization_key
"#{token}.#{crypto.thumbprint}"
end
def assign_attributes(attributes)
@status = attributes.fetch('status', 'pending')
@uri = attributes.fetch('uri')
@token = attributes.fetch('token')
end
def crypto
@crypto ||= Acme::Client::Crypto.new(@client.private_key)
end
end
class Acme::Client::Resources::Challenges::DNS01 < Acme::Client::Resources::Challenges::Base
CHALLENGE_TYPE = 'dns-01'.freeze
RECORD_NAME = '_acme-challenge'.freeze
RECORD_TYPE = 'TXT'.freeze
def record_name
RECORD_NAME
end
def record_type
RECORD_TYPE
end
def record_content
Base64.urlsafe_encode64(crypto.digest.digest(authorization_key)).sub(/[\s=]*\z/, '')
end
end
class Acme::Client::Resources::Challenges::HTTP01 < Acme::Client::Resources::Challenges::Base
CHALLENGE_TYPE = 'http-01'.freeze
CONTENT_TYPE = 'text/plain'.freeze
def content_type
CONTENT_TYPE
end
def file_content
authorization_key
end
def filename
".well-known/acme-challenge/#{token}"
end
end
class Acme::Client::Resources::Challenges::TLSSNI01 < Acme::Client::Resources::Challenges::Base
CHALLENGE_TYPE = 'tls-sni-01'.freeze
def hostname
digest = crypto.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
class Acme::Client::Resources::Registration
attr_reader :id, :key, :contact, :uri, :next_uri, :recover_uri, :term_of_service_uri
def initialize(client, response)
@client = client
@uri = response.headers['location']
assign_links(response.headers['Link'])
assign_attributes(response.body)