Skip to content
Open
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
66 changes: 66 additions & 0 deletions lib/puppet/application/ssl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ def help
* --target CERTNAME
Clean the specified device certificate instead of this host's certificate.

* --if-expiring-in DURATION
When renewing a certificate only renew if the certificate is valid for
less than this amount of time. Duration can be specified as a time
interval, such as 30s, 5m, 1h.

ACTIONS
-------

Expand Down Expand Up @@ -71,6 +76,11 @@ def help
for subsequent requests. If there is already an existing certificate, it
will be overwritten.

* renew_cert
Renew an existing and non-expired client certificate. When
`--if-expiring-in` option is specified, then renew the certificate only
if it's going to expire in the amount of time given.

* verify:
Verify the private key and certificate are present and match, verify the
certificate is issued by a trusted CA, and check revocation status.
Expand Down Expand Up @@ -98,6 +108,28 @@ def help
option('--localca')
option('--verbose', '-v')
option('--debug', '-d')
option('--if-expiring-in DURATION') do |arg|
options[:expiring_in_sec] = parse_duration(arg)
end

def parse_duration(value)
unit_map = {
"y" => 365 * 24 * 60 * 60,
"d" => 24 * 60 * 60,
"h" => 60 * 60,
"m" => 60,
"s" => 1
}
format = /^(\d+)(y|d|h|m|s)?$/

v = (value.is_a?(Integer) ? "#{value}s" : value)

if v =~ format
Regexp.last_match(1).to_i * unit_map[::Regexp.last_match(2) || 's']
else
raise ArgumentError, "Invalid duration format: #{value}"
end
end

def initialize(command_line = Puppet::Util::CommandLine.new)
super(command_line)
Expand Down Expand Up @@ -148,6 +180,8 @@ def main
unless cert
raise Puppet::Error, _("The certificate for '%{name}' has not yet been signed") % { name: certname }
end
when 'renew_cert'
renew_cert(certname, options[:expiring_in_sec])
when 'generate_request'
generate_request(certname)
when 'verify'
Expand Down Expand Up @@ -248,6 +282,38 @@ def download_cert(ssl_context)
raise Puppet::Error.new(_("Failed to download certificate: %{message}") % { message: e.message }, e)
end

def renew_cert(certname, expiring_in_sec_maybe)
ssl_context = @ssl_provider.load_context(certname: certname)

if expiring_in_sec_maybe && (ssl_context[:client_cert].not_after - Time.now) > expiring_in_sec_maybe
Puppet.info("Expiring in #{expiring_in_sec_maybe}")
Puppet.info _("Certificate '%{name}' is still valid until %{date}") % { name: certname, date: ssl_context[:client_cert].not_after }
return ssl_context[:client_cert]
end

Puppet.debug _("Renewing certificate '%{name}'") % { name: certname }
route = create_route(ssl_context)
_, x509 = route.post_certificate_renewal(ssl_context)
cert = OpenSSL::X509::Certificate.new(x509)
Puppet.info _("Downloaded certificate '%{name}' with fingerprint %{fingerprint}") % { name: Puppet[:certname], fingerprint: fingerprint(cert) }

# verify client cert before saving
@ssl_provider.create_context(
cacerts: ssl_context.cacerts, crls: ssl_context.crls, private_key: ssl_context.private_key, client_cert: cert
)
@cert_provider.save_client_cert(certname, cert)
@cert_provider.delete_request(certname)
cert
rescue Puppet::HTTP::ResponseError => e
if e.response.code == 404
nil
else
raise Puppet::Error.new(_("Failed to download certificate: %{message}") % { message: e.message }, e)
end
rescue => e
raise Puppet::Error.new(_("Failed to download certificate: %{message}") % { message: e.message }, e)
end

def verify(certname)
password = @cert_provider.load_private_key_password
ssl_context = @ssl_provider.load_context(certname: certname, password: password)
Expand Down
62 changes: 62 additions & 0 deletions spec/unit/application/ssl_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,68 @@ def expects_command_to_fail(message)
end
end

context 'when renewing a certificate' do
let(:renewed) do
# Create a new cert with the same private key ("renew" an existing certificate)
@ca.create_cert('ssl-client', @ca.ca_cert, @ca.key, reuse_key: @host[:private_key])
end

before do
ssl.command_line.args << 'renew_cert'
# This command requires an existing certificate
File.write(Puppet[:hostcert], @host[:cert].to_pem)
end

it 'renews a new cert' do
stub_request(:post, %r{puppet-ca/v1/certificate_renewal}).to_return(status: 200, body: renewed[:cert].to_pem)

expects_command_to_pass(%r{Downloaded certificate '#{name}' with fingerprint .*})

expect(File.read(Puppet[:hostcert])).to eq(renewed[:cert].to_pem)
end

context 'with --if-expiring-in=100y specified' do
before do
ssl.command_line.args << '--if-expiring-in' << '100y'
ssl.parse_options
end

it 'renews a cert' do
stub_request(:post, %r{puppet-ca/v1/certificate_renewal}).to_return(status: 200, body: renewed[:cert].to_pem)

expects_command_to_pass(%r{Downloaded certificate '#{name}' with fingerprint .*})

expect(File.read(Puppet[:hostcert])).to eq(renewed[:cert].to_pem)
end
end

context 'with --if-expiring-in=0 specified' do
before do
ssl.command_line.args << '--if-expiring-in' << '0y'
ssl.parse_options
end

it 'does not renew a cert' do
expects_command_to_pass(%r{Certificate '#{name}' is still valid until .*})

expect(File.read(Puppet[:hostcert])).to eq(@host[:cert].to_pem)
end
end

it "reports an error if the downloaded cert's public key doesn't match our private key" do
# generate a new host key, whose public key doesn't match the cert
private_key = OpenSSL::PKey::RSA.new(512)
File.write(Puppet[:hostprivkey], private_key.to_pem)
File.write(Puppet[:hostpubkey], private_key.public_key.to_pem)

stub_request(:post, %r{puppet-ca/v1/certificate_renewal}).to_return(status: 200, body: renewed[:cert].to_pem)

expects_command_to_fail(
%r{^Failed to download certificate: The certificate for 'CN=ssl-client' does not match its private key}
)
end
end

context 'when verifying' do
before do
ssl.command_line.args << 'verify'
Expand Down
Loading