Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
2 changes: 1 addition & 1 deletion acmesmith.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions lib/acmesmith/certificate.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>] 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)}"
Expand Down
53 changes: 53 additions & 0 deletions lib/acmesmith/challenge_responders/pebble_challtestsrv_http.rb
Original file line number Diff line number Diff line change
@@ -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
7 changes: 4 additions & 3 deletions lib/acmesmith/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions lib/acmesmith/ip_address_filter.rb
Original file line number Diff line number Diff line change
@@ -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
26 changes: 24 additions & 2 deletions lib/acmesmith/ordering_service.rb
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
require 'acmesmith/authorization_service'
require 'acmesmith/certificate'
require 'acmesmith/certificate_retrieving_service'
require 'ipaddr'

module Acmesmith
class OrderingService
class NotCompleted < StandardError; end

# @param acme [Acme::Client] ACME client
# @param common_name [String] Common Name for a ordering certificate
# @param identifiers [Array<String>] Array of domain names for a ordering certificate. common_name has to be explicitly included in this argument.
# @param identifiers [Array<String>] 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<Acmesmith::Config::ChallengeResponderRule>] responders
# @param chain_preferences [Array<Acmesmith::Config::ChainPreference>] chain_preferences
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
14 changes: 10 additions & 4 deletions lib/acmesmith/subject_name_filter.rb
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions spec/certificate_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 6 additions & 1 deletion spec/integration/pebble/integration_spec_config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
53 changes: 53 additions & 0 deletions spec/integration/pebble/pebble_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions spec/leaf_ip_san.pem
Original file line number Diff line number Diff line change
@@ -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-----
2 changes: 2 additions & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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(_) }

Expand Down
18 changes: 18 additions & 0 deletions spec/subject_name_filter_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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