Skip to content

Commit 3d0b55d

Browse files
(maint) Merge up b6f5ca6 to main
Generated by CI * commit 'b6f5ca628424af3650bb682a88a440d7078fd952': (packaging) Updating manpage file for 6.x (PUP-11522) Warn if client cert or private key is missing (PUP-11522) DRY client cert and private key validation (PUP-11522) Support mTLS with an external CA (PUP-11522) Include client cert if requested (PUP-11522) Add tests for system and external CA stores (PUP-11522) Ensure test is using the SSL_CERT_FILE Conflicts: man/man8/puppet-key.8 man/man8/puppet-man.8 man/man8/puppet-status.8
2 parents f58989b + b6f5ca6 commit 3d0b55d

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
@@ -476,7 +476,7 @@ def system_ssl_context
476476
cacerts = cert_provider.load_cacerts || []
477477

478478
ssl = Puppet::SSL::SSLProvider.new
479-
@default_system_ssl_context = ssl.create_system_context(cacerts: cacerts)
479+
@default_system_ssl_context = ssl.create_system_context(cacerts: cacerts, include_client_cert: true)
480480
ssl.print(@default_system_ssl_context)
481481
@default_system_ssl_context
482482
end

lib/puppet/ssl/ssl_provider.rb

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,18 @@ def create_root_context(cacerts:, crls: [], revocation: Puppet[:certificate_revo
5959
# refers to the cacerts bundle in the puppet-agent package.
6060
#
6161
# Connections made from the returned context will authenticate the server,
62-
# i.e. `VERIFY_PEER`, but will not use a client certificate and will not
63-
# perform revocation checking.
62+
# i.e. `VERIFY_PEER`, but will not use a client certificate (unless requested)
63+
# and will not perform revocation checking.
6464
#
6565
# @param cacerts [Array<OpenSSL::X509::Certificate>] Array of trusted CA certs
6666
# @param path [String, nil] A file containing additional trusted CA certs.
67+
# @param include_client_cert [true, false] If true, the client cert will be added to the context
68+
# allowing mutual TLS authentication. The default is false. If the client cert doesn't exist
69+
# then the option will be ignored.
6770
# @return [Puppet::SSL::SSLContext] A context to use to create connections
6871
# @raise (see #create_context)
6972
# @api private
70-
def create_system_context(cacerts:, path: Puppet[:ssl_trust_store])
73+
def create_system_context(cacerts:, path: Puppet[:ssl_trust_store], include_client_cert: false)
7174
store = create_x509_store(cacerts, [], false, include_system_store: true)
7275

7376
if path
@@ -88,6 +91,29 @@ def create_system_context(cacerts:, path: Puppet[:ssl_trust_store])
8891
end
8992
end
9093

94+
if include_client_cert
95+
cert_provider = Puppet::X509::CertProvider.new
96+
private_key = cert_provider.load_private_key(Puppet[:certname], required: false)
97+
unless private_key
98+
Puppet.warning("Private key for '#{Puppet[:certname]}' does not exist")
99+
end
100+
101+
client_cert = cert_provider.load_client_cert(Puppet[:certname], required: false)
102+
unless client_cert
103+
Puppet.warning("Client certificate for '#{Puppet[:certname]}' does not exist")
104+
end
105+
106+
if private_key && client_cert
107+
client_chain = resolve_client_chain(store, client_cert, private_key)
108+
109+
return Puppet::SSL::SSLContext.new(
110+
store: store, cacerts: cacerts, crls: [],
111+
private_key: private_key, client_cert: client_cert, client_chain: client_chain,
112+
revocation: false
113+
).freeze
114+
end
115+
end
116+
91117
Puppet::SSL::SSLContext.new(store: store, cacerts: cacerts, crls: [], revocation: false).freeze
92118
end
93119

@@ -124,15 +150,7 @@ def create_context(cacerts:, crls:, private_key:, client_cert:, revocation: Pupp
124150
raise ArgumentError, _("Client cert is missing") unless client_cert
125151

126152
store = create_x509_store(cacerts, crls, revocation, include_system_store: include_system_store)
127-
client_chain = verify_cert_with_store(store, client_cert)
128-
129-
if !private_key.is_a?(OpenSSL::PKey::RSA) && !private_key.is_a?(OpenSSL::PKey::EC)
130-
raise Puppet::SSL::SSLError, _("Unsupported key '%{type}'") % { type: private_key.class.name }
131-
end
132-
133-
unless client_cert.check_private_key(private_key)
134-
raise Puppet::SSL::SSLError, _("The certificate for '%{name}' does not match its private key") % { name: subject(client_cert) }
135-
end
153+
client_chain = resolve_client_chain(store, client_cert, private_key)
136154

137155
Puppet::SSL::SSLContext.new(
138156
store: store, cacerts: cacerts, crls: crls,
@@ -258,6 +276,20 @@ def revocation_mode(mode)
258276
end
259277
end
260278

279+
def resolve_client_chain(store, client_cert, private_key)
280+
client_chain = verify_cert_with_store(store, client_cert)
281+
282+
if !private_key.is_a?(OpenSSL::PKey::RSA) && !private_key.is_a?(OpenSSL::PKey::EC)
283+
raise Puppet::SSL::SSLError, _("Unsupported key '%{type}'") % { type: private_key.class.name }
284+
end
285+
286+
unless client_cert.check_private_key(private_key)
287+
raise Puppet::SSL::SSLError, _("The certificate for '%{name}' does not match its private key") % { name: subject(client_cert) }
288+
end
289+
290+
client_chain
291+
end
292+
261293
def verify_cert_with_store(store, cert)
262294
# StoreContext#initialize accepts a chain argument, but it's set to [] because
263295
# 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
@@ -530,6 +592,18 @@
530592
}.to raise_error(Puppet::SSL::SSLError, /Failed to load private key for host 'signed': Could not parse PKey/)
531593
end
532594
end
595+
596+
it 'does not trust the system ca store by default' do
597+
expect_any_instance_of(OpenSSL::X509::Store).to receive(:set_default_paths).never
598+
599+
subject.load_context
600+
end
601+
602+
it 'trusts the system ca store' do
603+
expect_any_instance_of(OpenSSL::X509::Store).to receive(:set_default_paths)
604+
605+
subject.load_context(include_system_store: true)
606+
end
533607
end
534608

535609
context 'when verifying requests' do

0 commit comments

Comments
 (0)