diff --git a/Gemfile.lock b/Gemfile.lock index c977e20..dcf9b76 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,7 +2,7 @@ PATH remote: . specs: acmesmith (2.9.0) - acme-client (>= 2.0.7, < 3) + acme-client (>= 2.0.29, < 3) aws-sdk-acm aws-sdk-route53 aws-sdk-s3 diff --git a/README.md b/README.md index 73e4e11..01ba0bb 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,8 @@ challenge_responders: - .example.org subject_name_regexp: - '\Aapp\d+.example.org\z' + subject_name_cidr: + - 192.0.2.0/24 - {RESPONDER_TYPE}: {RESPONDER_OPTIONS} diff --git a/acmesmith.gemspec b/acmesmith.gemspec index ee31539..a77b93a 100644 --- a/acmesmith.gemspec +++ b/acmesmith.gemspec @@ -31,7 +31,7 @@ Acmesmith is an [ACME (Automatic Certificate Management Environment)](https://gi spec.executables = spec.files.grep(%r{\Abin/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.add_dependency "acme-client", '>= 2.0.7', '< 3' + spec.add_dependency "acme-client", '>= 2.0.29', '< 3' # 2.0.29 introduced IP-SAN support spec.add_dependency "aws-sdk-acm" spec.add_dependency "aws-sdk-route53" spec.add_dependency "aws-sdk-s3" diff --git a/lib/acmesmith/certificate.rb b/lib/acmesmith/certificate.rb index bf2d02a..a5a922b 100644 --- a/lib/acmesmith/certificate.rb +++ b/lib/acmesmith/certificate.rb @@ -165,6 +165,13 @@ def sans end.map { |_| _[4..-1] } end + # Returns a list of IP address subject alternative names included in the certificate. + # Strips IP Address: prefix from returned values. + # @return [Array] Subject Alternative Names (iPAddress) + def ip_sans + all_sans.select { |san| san.start_with?('IP Address:') }.map { |_| _[11..-1] } + end + # @return [String] Version string (consists of NotBefore time & certificate serial) def version "#{certificate.not_before.utc.strftime('%Y%m%d-%H%M%S')}_#{certificate.serial.to_i.to_s(16)}" diff --git a/lib/acmesmith/challenge_responders/pebble_challtestsrv_http.rb b/lib/acmesmith/challenge_responders/pebble_challtestsrv_http.rb new file mode 100644 index 0000000..1647f15 --- /dev/null +++ b/lib/acmesmith/challenge_responders/pebble_challtestsrv_http.rb @@ -0,0 +1,53 @@ +require 'acmesmith/challenge_responders/base' +require 'net/http' +require 'uri' +require 'json' + +module Acmesmith + module ChallengeResponders + class PebbleChalltestsrvHttp < Base + def support?(type) + # Acme::Client::Resources::Challenges::HTTP01 + type == 'http-01' + end + + def initialize(url: 'http://localhost:8055') + warn_test + @url = URI.parse(url) + end + + attr_reader :url + + def respond(domain, challenge) + warn_test + + Net::HTTP.post( + URI.join(url, '/add-http01'), + { + token: challenge.token, + content: challenge.file_content, + }.to_json, + ).value + end + + def cleanup(domain, challenge) + warn_test + + Net::HTTP.post( + URI.join(url, '/del-http01'), + { + token: challenge.token, + }.to_json, + ).value + end + + def warn_test + unless ENV['ACMESMITH_ACKNOWLEDGE_PEBBLE_CHALLTESTSRV_IS_INSECURE'] + $stderr.puts '!!!!!!!!! WARNING WARNING WARNING !!!!!!!!!' + $stderr.puts '!!!! pebble-challtestsrv command is for TEST USAGE ONLY. It is trivially insecure, offering no authentication. Only use pebble-challtestsrv in a controlled test environment.' + $stderr.puts '!!!! https://github.com/letsencrypt/pebble/blob/master/cmd/pebble-challtestsrv/README.md' + end + end + end + end +end diff --git a/lib/acmesmith/client.rb b/lib/acmesmith/client.rb index 3bf2ea4..7a63efa 100644 --- a/lib/acmesmith/client.rb +++ b/lib/acmesmith/client.rb @@ -144,15 +144,16 @@ def autorenew(days: 30, remaining_life: nil, names: nil) puts " Not valid after: #{not_after} (lifetime=#{format_duration(lifetime+1)}, remaining=#{format_duration(remaining)}, #{"%0.2f" % (ratio.to_f*100)}%)" next unless has_to_renew - puts " * Renewing: #{cert.name.inspect}, SANs=#{cert.sans.join(',')}" - order_with_private_key(cert.name, *cert.sans, private_key: regenerate_private_key(cert.public_key)) + sans = cert.sans + cert.ip_sans + puts " * Renewing: #{cert.name.inspect}, SANs=#{sans.join(',')}" + order_with_private_key(cert.name, *sans, private_key: regenerate_private_key(cert.public_key)) end end def add_san(name, *add_sans) puts "=> reissuing #{name.inspect} with new SANs #{add_sans.join(?,)}" cert = load_certificate_from_storage(name) - sans = cert.sans + add_sans + sans = cert.sans + cert.ip_sans + add_sans puts " * SANs will be: #{sans.join(?,)}" order_with_private_key(cert.name, *sans, private_key: regenerate_private_key(cert.public_key)) end diff --git a/lib/acmesmith/ip_address_filter.rb b/lib/acmesmith/ip_address_filter.rb new file mode 100644 index 0000000..704bf1d --- /dev/null +++ b/lib/acmesmith/ip_address_filter.rb @@ -0,0 +1,23 @@ +require 'ipaddr' + +module Acmesmith + class IpAddressFilter + def initialize(cidr: nil) + @cidr = cidr && [*cidr].flatten.compact.map { |_| IPAddr.new(_) } + end + + def match?(ipaddr) + begin + ipaddr = IPAddr.new(ipaddr) + rescue IPAddr::InvalidAddressError + return false + end + + if @cidr + return false unless @cidr.any? { |_| _.include?(ipaddr) } + end + + true + end + end +end diff --git a/lib/acmesmith/ordering_service.rb b/lib/acmesmith/ordering_service.rb index 9a39c60..0f975d0 100644 --- a/lib/acmesmith/ordering_service.rb +++ b/lib/acmesmith/ordering_service.rb @@ -1,6 +1,7 @@ require 'acmesmith/authorization_service' require 'acmesmith/certificate' require 'acmesmith/certificate_retrieving_service' +require 'ipaddr' module Acmesmith class OrderingService @@ -8,7 +9,7 @@ class NotCompleted < StandardError; end # @param acme [Acme::Client] ACME client # @param common_name [String] Common Name for a ordering certificate - # @param identifiers [Array] Array of domain names for a ordering certificate. common_name has to be explicitly included in this argument. + # @param identifiers [Array] Array of domain names or IP addresses for a ordering certificate. common_name has to be explicitly included in this argument. # @param private_key [OpenSSL::PKey::PKey] Private key # @param challenge_responder_rules [Array] responders # @param chain_preferences [Array] chain_preferences @@ -46,7 +47,12 @@ def perform! puts puts "=> Placing an order" - @order = acme.new_order(identifiers: identifiers, not_before: not_before, not_after: not_after, profile: resolved_profile) + @order = acme.new_order( + identifiers: identifiers.map { |id| acme_identifier(id) }, + not_before: not_before, + not_after: not_after, + profile: resolved_profile, + ) puts " * URL: #{order.url}" ensure_authorization() @@ -125,5 +131,21 @@ def profile def csr @csr ||= Acme::Client::CertificateRequest.new(subject: { common_name: common_name }, names: sans, private_key: private_key) end + + private + + # Translate subject name to ACME identifier + def acme_identifier(name) + if %r{[/%]} =~ name # Reject CIDRs and IPv6 zone IDs + raise ArgumentError, "invalid character in subject name: #{name.inspect}" + end + + begin + IPAddr.new(name) # Test if it parses + { type: 'ip', value: name } + rescue IPAddr::InvalidAddressError + { type: 'dns', value: name } + end + end end end diff --git a/lib/acmesmith/subject_name_filter.rb b/lib/acmesmith/subject_name_filter.rb index 1afaf17..12f54ab 100644 --- a/lib/acmesmith/subject_name_filter.rb +++ b/lib/acmesmith/subject_name_filter.rb @@ -1,17 +1,23 @@ require 'acmesmith/domain_name_filter' +require 'acmesmith/ip_address_filter' module Acmesmith class SubjectNameFilter - def initialize(subject_name_exact: nil, subject_name_suffix: nil, subject_name_regexp: nil) + def initialize(subject_name_exact: nil, subject_name_suffix: nil, subject_name_regexp: nil, subject_name_cidr: nil) @domain_name_filter = DomainNameFilter.new( exact: subject_name_exact, suffix: subject_name_suffix, regexp: subject_name_regexp, - ) + ) if subject_name_exact || subject_name_suffix || subject_name_regexp + @ip_address_filter = IpAddressFilter.new( + cidr: subject_name_cidr, + ) if subject_name_cidr end - def match?(domain) - @domain_name_filter.match?(domain) + def match?(name) + return false if @domain_name_filter && !@domain_name_filter.match?(name) + return false if @ip_address_filter && !@ip_address_filter.match?(name) + true end end end diff --git a/spec/certificate_spec.rb b/spec/certificate_spec.rb index 41d05f0..3385ba9 100644 --- a/spec/certificate_spec.rb +++ b/spec/certificate_spec.rb @@ -145,4 +145,26 @@ subject(:fullchain) { certificate.version } it { is_expected.to eq("20200511-192010_fa5aa9032181a09b2cebec848658eb2c5f79") } end + + context "with IP-SAN certificate" do + let(:given_certificate) { PEM_LEAF_IPSAN } + + describe "#ip_sans" do + it "returns only IP addresses" do + expect(certificate.ip_sans).to contain_exactly('2406:DA14:DFE:C0D0:1F90:68C0:A067:569B', '35.74.65.6') + end + end + + describe "#sans" do + it "returns only DNS names" do + expect(certificate.sans).to contain_exactly('70ca8ae3ec7d3052.test.hanazuki.jp') + end + end + + describe "#all_sans" do + it "contains both DNS and IP entries" do + expect(certificate.all_sans).to contain_exactly('DNS:70ca8ae3ec7d3052.test.hanazuki.jp', 'IP Address:35.74.65.6', 'IP Address:2406:DA14:DFE:C0D0:1F90:68C0:A067:569B') + end + end + end end diff --git a/spec/integration/pebble/integration_spec_config.yml b/spec/integration/pebble/integration_spec_config.yml index 9feb22b..b68d2dd 100644 --- a/spec/integration/pebble/integration_spec_config.yml +++ b/spec/integration/pebble/integration_spec_config.yml @@ -7,7 +7,12 @@ storage: type: filesystem path: tmp/integration-pebble challenge_responders: - - pebble_challtestsrv_dns: {} + - filter: + subject_name_suffix: [".invalid"] + pebble_challtestsrv_dns: {} + - filter: + subject_name_cidr: ["127.0.0.0/8", "::1"] + pebble_challtestsrv_http: {} post_issuing_hooks: "flag.invalid": diff --git a/spec/integration/pebble/pebble_spec.rb b/spec/integration/pebble/pebble_spec.rb index 2064ed2..c07fca3 100644 --- a/spec/integration/pebble/pebble_spec.rb +++ b/spec/integration/pebble/pebble_spec.rb @@ -43,6 +43,22 @@ def self.stop RSpec.describe "Integration with Pebble", integration_pebble: true do PEBBLE_CONFIG = File.join(__dir__, 'integration_spec_config.yml') + def ip_sans_from_cert(pem) + OpenSSL::X509::Certificate.new(pem) + .extensions + .select { |_| _.oid == 'subjectAltName' } + .flat_map { |_| _.value.split(/,\s*/) } + .filter_map { |_| _[11..] if _.start_with?('IP Address:') } + end + + def dns_sans_from_cert(pem) + OpenSSL::X509::Certificate.new(pem) + .extensions + .select { |_| _.oid == 'subjectAltName' } + .flat_map { |_| _.value.split(/,\s*/) } + .filter_map { |_| _[4..] if _.start_with?('DNS:') } + end + def cmd(*args) ['bin/acmesmith', args.first, '-c', PEBBLE_CONFIG, *args[1..-1]] end @@ -162,6 +178,43 @@ def cmd(*args) end end + context 'IP SAN certificate' do + it 'issues a certificate with an IP SAN' do + system(*cmd('order', 'ipsan.invalid', '127.0.0.1'), exception: true) + + pem = IO.popen(cmd('show-certificate', '--type=certificate', 'ipsan.invalid'), 'r', &:read) + expect(ip_sans_from_cert(pem)).to contain_exactly('127.0.0.1') + expect(dns_sans_from_cert(pem)).to contain_exactly('ipsan.invalid') + end + + it 'preserves the IP SAN through add-san DNS' do + system(*cmd('order', 'ipsan-add-dns.invalid', '127.0.0.1', '::1'), exception: true) + + system(*cmd('add-san', 'ipsan-add-dns.invalid', 'another.invalid'), exception: true) + pem = IO.popen(cmd('show-certificate', '--type=certificate', 'ipsan-add-dns.invalid'), 'r', &:read) + expect(ip_sans_from_cert(pem)).to contain_exactly('127.0.0.1', '0:0:0:0:0:0:0:1') + expect(dns_sans_from_cert(pem)).to contain_exactly('ipsan-add-dns.invalid', 'another.invalid') + end + + it 'preserves the IP SAN through add-san IP' do + system(*cmd('order', 'ipsan-add-ip.invalid', '127.0.0.1'), exception: true) + + system(*cmd('add-san', 'ipsan-add-ip.invalid', '127.0.0.2', '::1'), exception: true) + pem = IO.popen(cmd('show-certificate', '--type=certificate', 'ipsan-add-ip.invalid'), 'r', &:read) + expect(ip_sans_from_cert(pem)).to contain_exactly('127.0.0.1', '127.0.0.2', '0:0:0:0:0:0:0:1') + expect(dns_sans_from_cert(pem)).to contain_exactly('ipsan-add-ip.invalid') + end + + it 'preserves the SANs through autorenew' do + system(*cmd('order', 'ipsan-autorenew.invalid', '127.0.0.1'), exception: true) + + system(*cmd('autorenew', 'ipsan-autorenew.invalid', '--days', '9999'), exception: true) + pem = IO.popen(cmd('show-certificate', '--type=certificate', 'ipsan-autorenew.invalid'), 'r', &:read) + expect(ip_sans_from_cert(pem)).to contain_exactly('127.0.0.1') + expect(dns_sans_from_cert(pem)).to contain_exactly('ipsan-autorenew.invalid') + end + end + after(:all) do PebbleRunner.stop end diff --git a/spec/leaf_ip_san.pem b/spec/leaf_ip_san.pem new file mode 100644 index 0000000..674f2d5 --- /dev/null +++ b/spec/leaf_ip_san.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDpzCCAy2gAwIBAgISLEmSC7TddBEDnXWjCTw3GkB1MAoGCCqGSM49BAMDMFEx +CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MSowKAYDVQQDEyEo +U1RBR0lORykgQXJ0aWZpY2lhbCBBbWFyYW50aCBZRTEwHhcNMjYwMzE4MTgxMTI4 +WhcNMjYwMzI1MTAxMTI3WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEk0dQ +cs9yUu7MnkRW17ZBkebkSvZvk/IgfWB+PMFfWbybfOrXf7weqJZBM7E3skCkfcAA +YGoc43lvwj7wjuyupaOCAjQwggIwMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK +BggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFLPMg2wd5p0Qp3r3 +HFHxmn5kpeb1MDcGCCsGAQUFBwEBBCswKTAnBggrBgEFBQcwAoYbaHR0cDovL3N0 +Zy15ZTEuaS5sZW5jci5vcmcvMEcGA1UdEQEB/wQ9MDuCITcwY2E4YWUzZWM3ZDMw +NTIudGVzdC5oYW5henVraS5qcIcQJAbaFA3+wNAfkGjAoGdWm4cEI0pBBjATBgNV +HSAEDDAKMAgGBmeBDAECATAzBgNVHR8ELDAqMCigJqAkhiJodHRwOi8vc3RnLXll +MS5jLmxlbmNyLm9yZy8xMTguY3JsMIIBDAYKKwYBBAHWeQIEAgSB/QSB+gD4AH4A +WUSCTUXhQ2fKUHvFtaSmfyox7je4S+Qnx6+LKKVk0SsAAAGdAlrPrQAIAAAFAAb1 +8FEEAwBHMEUCIBSH3WxHl24D5KJm2RoOkqDcUbkF4t1+Lemeph69UYESAiEAv6jH +Uapiogz0wKkC/Ii7NToPXXBsnDyZmLMAPuED6+cAdgDDvwOn4cqIQcYHuuP/QnD8 +pexFsYbrvk4s8/x3hjD19gAAAZ0CWtSHAAAEAwBHMEUCIQD/XRjgy4HHP1Ftw38S +n8Y+stIROoFqlaCIoboU+eOddAIgJx1sAaZBQDjN9xe5d35aYgLPCg5IeW7Tqf6a +MgVYcvwwCgYIKoZIzj0EAwMDaAAwZQIxAKMqCflfGYU9pAGQ1vszfKMdMfBPowxG +zj3pKwVaURr2MXND09I6q2krU/5F3A28fgIwBqoEktu0yIqwCg9k5hHliqQFJdb/ +WSg9UpQkL0lJ7ewR2Zq8Wxvs//cvP+vBr8sc +-----END CERTIFICATE----- diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 254c10c..5603471 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -11,6 +11,8 @@ PEM_LEAF = File.read(File.join(__dir__, 'leaf.pem')) LEAF = OpenSSL::X509::Certificate.new(PEM_LEAF) +PEM_LEAF_IPSAN = File.read(File.join(__dir__, 'leaf_ip_san.pem')) + PEM_CHAIN = File.read(File.join(__dir__, 'chain.pem')) CHAIN = PEM_CHAIN.each_line.slice_before(/^-----BEGIN CERTIFICATE-----$/).map(&:join).map { |_| OpenSSL::X509::Certificate.new(_) } diff --git a/spec/subject_name_filter_spec.rb b/spec/subject_name_filter_spec.rb index 201a3c5..316456e 100644 --- a/spec/subject_name_filter_spec.rb +++ b/spec/subject_name_filter_spec.rb @@ -66,5 +66,23 @@ expect(subject.match?('other.example.com')).to eq false end end + + context 'with cidr filter' do + subject do + described_class.new( + subject_name_cidr: ['192.0.2.0/25', '2001:db8::/64'], + ) + end + + it 'matches domain in the subnet' do + expect(subject.match?('192.0.2.1')).to eq true + expect(subject.match?('2001:db8::1/64')).to eq true + end + + it 'does not match domain not in the subnet' do + expect(subject.match?('192.0.2.255')).to eq false + expect(subject.match?('2001:db8:f::1/64')).to eq false + end + end end end