Skip to content

Commit dce5ef4

Browse files
committed
(PUP-10639) Refresh CA certs
Refresh CA certs if the last update time plus the `:ca_refresh_interval` is in the past. If the certs are updated, then force the CRLs to be updated too, since if the CA bundle includes a new root or intermediate CA, we'll need its corresponding non-expired CRL, to check the full chain. If the certs aren't updated or there's an networking error, then continue using the CA started with, and try again based on the next `:ca_refresh_interval`.
1 parent f843c1f commit dce5ef4

File tree

2 files changed

+149
-11
lines changed

2 files changed

+149
-11
lines changed

lib/puppet/ssl/state_machine.rb

Lines changed: 80 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,23 @@ def initialize(machine)
5050
def next_state
5151
Puppet.debug("Loading CA certs")
5252

53+
force_crl_refresh = false
54+
5355
cacerts = @cert_provider.load_cacerts
5456
if cacerts
5557
next_ctx = @ssl_provider.create_root_context(cacerts: cacerts, revocation: false)
58+
59+
now = Time.now
60+
last_update = @cert_provider.ca_last_update
61+
if needs_refresh?(now, last_update)
62+
# set last updated time first, then make a best effort to refresh
63+
@cert_provider.ca_last_update = now
64+
65+
# If we refresh the CA, then we need to force the CRL to be refreshed too,
66+
# since if there is a new CA in the chain, then we need its CRL to check
67+
# the full chain for revocation status.
68+
next_ctx, force_crl_refresh = refresh_ca(next_ctx, last_update)
69+
end
5670
else
5771
route = @machine.session.route_to(:ca, ssl_context: @ssl_context)
5872
_, pem = route.get_certificate(Puppet::SSL::CA_NAME, ssl_context: @ssl_context)
@@ -74,7 +88,7 @@ def next_state
7488
@cert_provider.save_cacerts(cacerts)
7589
end
7690

77-
NeedCRLs.new(@machine, next_ctx)
91+
NeedCRLs.new(@machine, next_ctx, force_crl_refresh)
7892
rescue OpenSSL::X509::CertificateError => e
7993
Error.new(@machine, e.message, e)
8094
rescue Puppet::HTTP::ResponseError => e
@@ -84,6 +98,49 @@ def next_state
8498
to_error(_('Could not download CA certificate: %{message}') % { message: e.message }, e)
8599
end
86100
end
101+
102+
private
103+
104+
def needs_refresh?(now, last_update)
105+
return true if last_update.nil?
106+
107+
ca_ttl = Puppet[:ca_refresh_interval]
108+
return false unless ca_ttl
109+
110+
now.to_i > last_update.to_i + ca_ttl
111+
end
112+
113+
def refresh_ca(ssl_ctx, last_update)
114+
Puppet.info(_("Refreshing CA certificate"))
115+
116+
# return the next_ctx containing the updated ca
117+
[download_ca(ssl_ctx, last_update), true]
118+
rescue Puppet::HTTP::ResponseError => e
119+
if e.response.code == 304
120+
Puppet.info(_("CA certificate is unmodified, using existing CA certificate"))
121+
else
122+
Puppet.info(_("Failed to refresh CA certificate, using existing CA certificate: %{message}") % {message: e.message})
123+
end
124+
125+
# return the original ssl_ctx
126+
[ssl_ctx, false]
127+
rescue Puppet::HTTP::HTTPError => e
128+
Puppet.warning(_("Failed to refresh CA certificate, using existing CA certificate: %{message}") % {message: e.message})
129+
130+
# return the original ssl_ctx
131+
[ssl_ctx, false]
132+
end
133+
134+
def download_ca(ssl_ctx, last_update)
135+
route = @machine.session.route_to(:ca, ssl_context: ssl_ctx)
136+
_, pem = route.get_certificate(Puppet::SSL::CA_NAME, if_modified_since: last_update, ssl_context: ssl_ctx)
137+
cacerts = @cert_provider.load_cacerts_from_pem(pem)
138+
# verify cacerts before saving
139+
next_ctx = @ssl_provider.create_root_context(cacerts: cacerts, revocation: false)
140+
@cert_provider.save_cacerts(cacerts)
141+
142+
next_ctx
143+
end
87144
end
88145

89146
# If revocation is enabled, load CRLs or download them, using the CA bundle
@@ -93,6 +150,13 @@ def next_state
93150
# for which we don't have a CRL
94151
#
95152
class NeedCRLs < SSLState
153+
attr_reader :force_crl_refresh
154+
155+
def initialize(machine, ssl_context, force_crl_refresh = false)
156+
super(machine, ssl_context)
157+
@force_crl_refresh = force_crl_refresh
158+
end
159+
96160
def next_state
97161
Puppet.debug("Loading CRLs")
98162

@@ -102,15 +166,12 @@ def next_state
102166
if crls
103167
next_ctx = @ssl_provider.create_root_context(cacerts: ssl_context[:cacerts], crls: crls)
104168

105-
crl_ttl = Puppet[:crl_refresh_interval]
106-
if crl_ttl
107-
last_update = @cert_provider.crl_last_update
108-
now = Time.now
109-
if last_update.nil? || now.to_i > last_update.to_i + crl_ttl
110-
# set last updated time first, then make a best effort to refresh
111-
@cert_provider.crl_last_update = now
112-
next_ctx = refresh_crl(next_ctx, last_update)
113-
end
169+
now = Time.now
170+
last_update = @cert_provider.crl_last_update
171+
if needs_refresh?(now, last_update)
172+
# set last updated time first, then make a best effort to refresh
173+
@cert_provider.crl_last_update = now
174+
next_ctx = refresh_crl(next_ctx, last_update)
114175
end
115176
else
116177
next_ctx = download_crl(@ssl_context, nil)
@@ -133,6 +194,15 @@ def next_state
133194

134195
private
135196

197+
def needs_refresh?(now, last_update)
198+
return true if @force_crl_refresh || last_update.nil?
199+
200+
crl_ttl = Puppet[:crl_refresh_interval]
201+
return false unless crl_ttl
202+
203+
now.to_i > last_update.to_i + crl_ttl
204+
end
205+
136206
def refresh_crl(ssl_ctx, last_update)
137207
Puppet.info(_("Refreshing CRL"))
138208

spec/unit/ssl/state_machine_spec.rb

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@
3030
Puppet[:daemonize] = false
3131
Puppet[:ssl_lockfile] = tmpfile('ssllock')
3232
allow(Kernel).to receive(:sleep)
33-
allow_any_instance_of(Puppet::X509::CertProvider).to receive(:crl_last_update).and_return(Time.now + (5 * 60))
33+
future = Time.now + (5 * 60)
34+
allow_any_instance_of(Puppet::X509::CertProvider).to receive(:crl_last_update).and_return(future)
35+
allow_any_instance_of(Puppet::X509::CertProvider).to receive(:ca_last_update).and_return(future)
3436
end
3537

3638
def expected_digest(name, content)
@@ -396,6 +398,16 @@ def expect_lockfile_to_contain(pid)
396398
expect(File).to_not exist(Puppet[:localcacert])
397399
end
398400

401+
it 'skips CA refresh if it has not expired' do
402+
Puppet[:ca_refresh_interval] = '1y'
403+
Puppet::FileSystem.touch(Puppet[:localcacert], mtime: Time.now)
404+
405+
allow_any_instance_of(Puppet::X509::CertProvider).to receive(:load_cacerts).and_return(cacerts)
406+
407+
# we're expecting a net/http request to never be made
408+
state.next_state
409+
end
410+
399411
context 'when verifying CA cert bundle' do
400412
before :each do
401413
allow(cert_provider).to receive(:load_cacerts).and_return(nil)
@@ -436,6 +448,61 @@ def expect_lockfile_to_contain(pid)
436448
expect(st.message).to eq("CA bundle with digest (SHA256) #{fingerprint} did not match expected digest WR:ON:G!")
437449
end
438450
end
451+
452+
context 'when refreshing a CA bundle' do
453+
before :each do
454+
Puppet[:ca_refresh_interval] = '1s'
455+
allow_any_instance_of(Puppet::X509::CertProvider).to receive(:load_cacerts).and_return(cacerts)
456+
457+
yesterday = Time.now - (24 * 60 * 60)
458+
allow_any_instance_of(Puppet::X509::CertProvider).to receive(:ca_last_update).and_return(yesterday)
459+
end
460+
461+
let(:new_ca_bundle) do
462+
# add 'unknown' cert to the bundle
463+
[cacert, cert_fixture('intermediate.pem'), cert_fixture('unknown-ca.pem')].map(&:to_pem)
464+
end
465+
466+
it 'uses the local CA if it has not been modified' do
467+
stub_request(:get, %r{puppet-ca/v1/certificate/ca}).to_return(status: 304)
468+
469+
expect(state.next_state.ssl_context.cacerts).to eq(cacerts)
470+
end
471+
472+
it 'uses the local CA if refreshing fails in HTTP layer' do
473+
stub_request(:get, %r{puppet-ca/v1/certificate/ca}).to_return(status: 503)
474+
475+
expect(state.next_state.ssl_context.cacerts).to eq(cacerts)
476+
end
477+
478+
it 'uses the local CA if refreshing fails in TCP layer' do
479+
stub_request(:get, %r{puppet-ca/v1/certificate/ca}).to_raise(Errno::ECONNREFUSED)
480+
481+
expect(state.next_state.ssl_context.cacerts).to eq(cacerts)
482+
end
483+
484+
it 'uses the updated crl for the future requests' do
485+
stub_request(:get, %r{puppet-ca/v1/certificate/ca}).to_return(status: 200, body: new_ca_bundle.join)
486+
487+
expect(state.next_state.ssl_context.cacerts.map(&:to_pem)).to eq(new_ca_bundle)
488+
end
489+
490+
it 'updates the `last_update` time' do
491+
stub_request(:get, %r{puppet-ca/v1/certificate/ca}).to_return(status: 200, body: new_ca_bundle.join)
492+
493+
expect_any_instance_of(Puppet::X509::CertProvider).to receive(:ca_last_update=).with(be_within(60).of(Time.now))
494+
495+
state.next_state
496+
end
497+
498+
it 'forces the NeedCRLs to refresh' do
499+
stub_request(:get, %r{puppet-ca/v1/certificate/ca}).to_return(status: 200, body: new_ca_bundle.join)
500+
501+
st = state.next_state
502+
expect(st).to be_an_instance_of(Puppet::SSL::StateMachine::NeedCRLs)
503+
expect(st.force_crl_refresh).to eq(true)
504+
end
505+
end
439506
end
440507

441508
context 'NeedCRLs' do
@@ -533,6 +600,7 @@ def expect_lockfile_to_contain(pid)
533600

534601
allow_any_instance_of(Puppet::X509::CertProvider).to receive(:load_crls).and_return(crls)
535602

603+
# we're expecting a net/http request to never be made
536604
state.next_state
537605
end
538606

0 commit comments

Comments
 (0)