Skip to content

Commit 16d5119

Browse files
authored
Merge pull request #8908 from joshcooper/external_ca_with_client_certs_11522
(PUP-11522) Support mutual TLS when using an external CA
2 parents a032e52 + 6923b0e commit 16d5119

File tree

5 files changed

+148
-25
lines changed

5 files changed

+148
-25
lines changed

lib/puppet/http/client.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -426,7 +426,7 @@ def system_ssl_context
426426
cacerts = cert_provider.load_cacerts || []
427427

428428
ssl = Puppet::SSL::SSLProvider.new
429-
@default_system_ssl_context = ssl.create_system_context(cacerts: cacerts)
429+
@default_system_ssl_context = ssl.create_system_context(cacerts: cacerts, include_client_cert: true)
430430
ssl.print(@default_system_ssl_context)
431431
@default_system_ssl_context
432432
end

lib/puppet/ssl/ssl_provider.rb

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,18 @@ def create_root_context(cacerts:, crls: [], revocation: Puppet[:certificate_revo
4242
# refers to the cacerts bundle in the puppet-agent package.
4343
#
4444
# Connections made from the returned context will authenticate the server,
45-
# i.e. `VERIFY_PEER`, but will not use a client certificate and will not
46-
# perform revocation checking.
45+
# i.e. `VERIFY_PEER`, but will not use a client certificate (unless requested)
46+
# and will not perform revocation checking.
4747
#
4848
# @param cacerts [Array<OpenSSL::X509::Certificate>] Array of trusted CA certs
4949
# @param path [String, nil] A file containing additional trusted CA certs.
50+
# @param include_client_cert [true, false] If true, the client cert will be added to the context
51+
# allowing mutual TLS authentication. The default is false. If the client cert doesn't exist
52+
# then the option will be ignored.
5053
# @return [Puppet::SSL::SSLContext] A context to use to create connections
5154
# @raise (see #create_context)
5255
# @api private
53-
def create_system_context(cacerts:, path: Puppet[:ssl_trust_store])
56+
def create_system_context(cacerts:, path: Puppet[:ssl_trust_store], include_client_cert: false)
5457
store = create_x509_store(cacerts, [], false, include_system_store: true)
5558

5659
if path
@@ -71,6 +74,29 @@ def create_system_context(cacerts:, path: Puppet[:ssl_trust_store])
7174
end
7275
end
7376

77+
if include_client_cert
78+
cert_provider = Puppet::X509::CertProvider.new
79+
private_key = cert_provider.load_private_key(Puppet[:certname], required: false)
80+
unless private_key
81+
Puppet.warning("Private key for '#{Puppet[:certname]}' does not exist")
82+
end
83+
84+
client_cert = cert_provider.load_client_cert(Puppet[:certname], required: false)
85+
unless client_cert
86+
Puppet.warning("Client certificate for '#{Puppet[:certname]}' does not exist")
87+
end
88+
89+
if private_key && client_cert
90+
client_chain = resolve_client_chain(store, client_cert, private_key)
91+
92+
return Puppet::SSL::SSLContext.new(
93+
store: store, cacerts: cacerts, crls: [],
94+
private_key: private_key, client_cert: client_cert, client_chain: client_chain,
95+
revocation: false
96+
).freeze
97+
end
98+
end
99+
74100
Puppet::SSL::SSLContext.new(store: store, cacerts: cacerts, crls: [], revocation: false).freeze
75101
end
76102

@@ -107,15 +133,7 @@ def create_context(cacerts:, crls:, private_key:, client_cert:, revocation: Pupp
107133
raise ArgumentError, _("Client cert is missing") unless client_cert
108134

109135
store = create_x509_store(cacerts, crls, revocation, include_system_store: include_system_store)
110-
client_chain = verify_cert_with_store(store, client_cert)
111-
112-
if !private_key.is_a?(OpenSSL::PKey::RSA) && !private_key.is_a?(OpenSSL::PKey::EC)
113-
raise Puppet::SSL::SSLError, _("Unsupported key '%{type}'") % { type: private_key.class.name }
114-
end
115-
116-
unless client_cert.check_private_key(private_key)
117-
raise Puppet::SSL::SSLError, _("The certificate for '%{name}' does not match its private key") % { name: subject(client_cert) }
118-
end
136+
client_chain = resolve_client_chain(store, client_cert, private_key)
119137

120138
Puppet::SSL::SSLContext.new(
121139
store: store, cacerts: cacerts, crls: crls,
@@ -241,6 +259,20 @@ def revocation_mode(mode)
241259
end
242260
end
243261

262+
def resolve_client_chain(store, client_cert, private_key)
263+
client_chain = verify_cert_with_store(store, client_cert)
264+
265+
if !private_key.is_a?(OpenSSL::PKey::RSA) && !private_key.is_a?(OpenSSL::PKey::EC)
266+
raise Puppet::SSL::SSLError, _("Unsupported key '%{type}'") % { type: private_key.class.name }
267+
end
268+
269+
unless client_cert.check_private_key(private_key)
270+
raise Puppet::SSL::SSLError, _("The certificate for '%{name}' does not match its private key") % { name: subject(client_cert) }
271+
end
272+
273+
client_chain
274+
end
275+
244276
def verify_cert_with_store(store, cert)
245277
# StoreContext#initialize accepts a chain argument, but it's set to [] because
246278
# puppet requires any intermediate CA certs needed to complete the client's

spec/integration/http/client_spec.rb

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,13 @@
7777
}
7878
}
7979

80-
let(:systemstore) do
81-
res = tmpfile('systemstore')
80+
let(:cert_file) do
81+
res = tmpfile('cert_file')
8282
File.write(res, https_server.ca_cert)
8383
res
8484
end
8585

86-
it "mutually authenticates the connection" do
86+
it "mutually authenticates the connection using an explicit context" do
8787
client_context = ssl_provider.create_context(
8888
cacerts: [https_server.ca_cert], crls: [https_server.ca_crl],
8989
client_cert: https_server.server_cert, private_key: https_server.server_key
@@ -95,10 +95,27 @@
9595
end
9696
end
9797

98+
it "mutually authenticates the connection when the client and server certs are issued from different CAs" do
99+
# this is the client cert's CA, key and cert
100+
Puppet[:localcacert] = fixtures('ssl/unknown-ca.pem')
101+
Puppet[:hostprivkey] = fixtures('ssl/unknown-127.0.0.1-key.pem')
102+
Puppet[:hostcert] = fixtures('ssl/unknown-127.0.0.1.pem')
103+
104+
# this is the server cert's CA that the client needs in order to authenticate the server
105+
Puppet[:ssl_trust_store] = fixtures('ssl/ca.pem')
106+
107+
# need to pass both the client and server CAs. The former is needed so the server can authenticate our client cert
108+
https_server = PuppetSpec::HTTPSServer.new(ca_cert: [cert_fixture('ca.pem'), cert_fixture('unknown-ca.pem')])
109+
https_server.start_server(ctx_proc: ctx_proc) do |port|
110+
res = client.get(URI("https://127.0.0.1:#{port}"), options: {include_system_store: true})
111+
expect(res).to be_success
112+
end
113+
end
114+
98115
it "connects when the server's CA is in the system store and the connection is mutually authenticated using create_context" do
99-
Puppet::Util.withenv("SSL_CERT_FILE" => systemstore) do
116+
Puppet::Util.withenv("SSL_CERT_FILE" => cert_file) do
100117
client_context = ssl_provider.create_context(
101-
cacerts: [https_server.ca_cert], crls: [https_server.ca_crl],
118+
cacerts: [], crls: [],
102119
client_cert: https_server.server_cert, private_key: https_server.server_key,
103120
revocation: false, include_system_store: true
104121
)
@@ -109,8 +126,8 @@
109126
end
110127
end
111128

112-
it "connects when the server's CA is in the system store and the connection is mutually authenticated uning load_context" do
113-
Puppet::Util.withenv("SSL_CERT_FILE" => systemstore) do
129+
it "connects when the server's CA is in the system store and the connection is mutually authenticated using load_context" do
130+
Puppet::Util.withenv("SSL_CERT_FILE" => cert_file) do
114131
client_context = ssl_provider.load_context(revocation: false, include_system_store: true)
115132
https_server.start_server(ctx_proc: ctx_proc) do |port|
116133
res = client.get(URI("https://127.0.0.1:#{port}"), options: {ssl_context: client_context})
@@ -132,12 +149,12 @@
132149

133150
it "connects when the server's CA is in the system store" do
134151
# create a temp cacert bundle
135-
ssl_file = tmpfile('systemstore')
136-
File.write(ssl_file, https_server.ca_cert)
152+
cert_file = tmpfile('cert_file')
153+
File.write(cert_file, https_server.ca_cert)
137154

138155
# override path to system cacert bundle, this must be done before
139156
# the SSLContext is created and the call to X509::Store.set_default_paths
140-
Puppet::Util.withenv("SSL_CERT_FILE" => ssl_file) do
157+
Puppet::Util.withenv("SSL_CERT_FILE" => cert_file) do
141158
system_context = ssl_provider.create_system_context(cacerts: [])
142159
https_server.start_server do |port|
143160
res = client.get(URI("https://127.0.0.1:#{port}"), options: {ssl_context: system_context})

spec/lib/puppet_spec/https.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def start_server(ctx_proc: nil, response_proc: nil, &block)
4040

4141
IO.pipe {|stop_pipe_r, stop_pipe_w|
4242
store = OpenSSL::X509::Store.new
43-
store.add_cert(@ca_cert)
43+
Array(@ca_cert).each { |c| store.add_cert(c) }
4444
store.purpose = OpenSSL::X509::PURPOSE_SSL_CLIENT
4545
ctx = OpenSSL::SSL::SSLContext.new
4646
ctx.cert_store = store

spec/unit/ssl/ssl_provider_spec.rb

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,12 +113,21 @@
113113
}.to raise_error(/can't modify frozen/)
114114
end
115115

116-
it 'trusts system ca store' do
116+
it 'trusts system ca store by default' do
117117
expect_any_instance_of(OpenSSL::X509::Store).to receive(:set_default_paths)
118118

119119
subject.create_system_context(cacerts: [])
120120
end
121121

122+
it 'trusts an external ca store' do
123+
path = tmpfile('system_cacerts')
124+
File.write(path, cert_fixture('ca.pem').to_pem)
125+
126+
expect_any_instance_of(OpenSSL::X509::Store).to receive(:add_file).with(path)
127+
128+
subject.create_system_context(cacerts: [], path: path)
129+
end
130+
122131
it 'verifies peer' do
123132
sslctx = subject.create_system_context(cacerts: [])
124133
expect(sslctx.verify_peer).to eq(true)
@@ -135,6 +144,47 @@
135144
expect(sslctx.private_key).to be_nil
136145
end
137146

147+
it 'includes the client cert and private key when requested' do
148+
Puppet[:hostcert] = fixtures('ssl/signed.pem')
149+
Puppet[:hostprivkey] = fixtures('ssl/signed-key.pem')
150+
sslctx = subject.create_system_context(cacerts: [], include_client_cert: true)
151+
expect(sslctx.client_cert).to be_an(OpenSSL::X509::Certificate)
152+
expect(sslctx.private_key).to be_an(OpenSSL::PKey::RSA)
153+
end
154+
155+
it 'ignores non-existent client cert and private key when requested' do
156+
Puppet[:certname] = 'doesnotexist'
157+
sslctx = subject.create_system_context(cacerts: [], include_client_cert: true)
158+
expect(sslctx.client_cert).to be_nil
159+
expect(sslctx.private_key).to be_nil
160+
end
161+
162+
it 'warns if the client cert does not exist' do
163+
Puppet[:certname] = 'missingcert'
164+
Puppet[:hostprivkey] = fixtures('ssl/signed-key.pem')
165+
166+
expect(Puppet).to receive(:warning).with("Client certificate for 'missingcert' does not exist")
167+
subject.create_system_context(cacerts: [], include_client_cert: true)
168+
end
169+
170+
it 'warns if the private key does not exist' do
171+
Puppet[:certname] = 'missingkey'
172+
Puppet[:hostcert] = fixtures('ssl/signed.pem')
173+
174+
expect(Puppet).to receive(:warning).with("Private key for 'missingkey' does not exist")
175+
subject.create_system_context(cacerts: [], include_client_cert: true)
176+
end
177+
178+
it 'raises if client cert and private key are mismatched' do
179+
Puppet[:hostcert] = fixtures('ssl/signed.pem')
180+
Puppet[:hostprivkey] = fixtures('ssl/127.0.0.1-key.pem')
181+
182+
expect {
183+
subject.create_system_context(cacerts: [], include_client_cert: true)
184+
}.to raise_error(Puppet::SSL::SSLError,
185+
"The certificate for 'CN=signed' does not match its private key")
186+
end
187+
138188
it 'trusts additional system certs' do
139189
path = tmpfile('system_cacerts')
140190
File.write(path, cert_fixture('ca.pem').to_pem)
@@ -448,6 +498,18 @@
448498
sslctx = subject.create_context(**config)
449499
expect(sslctx.verify_peer).to eq(true)
450500
end
501+
502+
it 'does not trust the system ca store by default' do
503+
expect_any_instance_of(OpenSSL::X509::Store).to receive(:set_default_paths).never
504+
505+
subject.create_context(**config)
506+
end
507+
508+
it 'trusts the system ca store' do
509+
expect_any_instance_of(OpenSSL::X509::Store).to receive(:set_default_paths)
510+
511+
subject.create_context(**config.merge(include_system_store: true))
512+
end
451513
end
452514

453515
context 'when loading an ssl context' do
@@ -528,6 +590,18 @@
528590
subject.load_context(password: 'wrongpassword')
529591
}.to raise_error(Puppet::SSL::SSLError, /Failed to load private key for host 'signed': Could not parse PKey/)
530592
end
593+
594+
it 'does not trust the system ca store by default' do
595+
expect_any_instance_of(OpenSSL::X509::Store).to receive(:set_default_paths).never
596+
597+
subject.load_context
598+
end
599+
600+
it 'trusts the system ca store' do
601+
expect_any_instance_of(OpenSSL::X509::Store).to receive(:set_default_paths)
602+
603+
subject.load_context(include_system_store: true)
604+
end
531605
end
532606

533607
context 'when verifying requests' do

0 commit comments

Comments
 (0)