diff --git a/lib/puppet/provider/java_ks/keytool.rb b/lib/puppet/provider/java_ks/keytool.rb index fc3752c..5e44752 100644 --- a/lib/puppet/provider/java_ks/keytool.rb +++ b/lib/puppet/provider/java_ks/keytool.rb @@ -1,372 +1,373 @@ require 'openssl' require 'timeout' require 'puppet/util/filetype' Puppet::Type.type(:java_ks).provide(:keytool) do desc 'Uses a combination of openssl and keytool to manage Java keystores' def command_keytool 'keytool' end # Keytool can only import a keystore if the format is pkcs12. Generating and # importing a keystore is used to add private_key and certificate pairs. def to_pkcs12(path) case private_key_type when :rsa pkey = OpenSSL::PKey::RSA.new File.read(private_key), password when :dsa pkey = OpenSSL::PKey::DSA.new File.read(private_key), password when :ec pkey = OpenSSL::PKey::EC.new File.read(private_key), password end if chain x509_cert = OpenSSL::X509::Certificate.new File.read certificate chain_certs = get_chain(chain) else chain_certs = get_chain(certificate) x509_cert = chain_certs.shift end pkcs12 = OpenSSL::PKCS12.create(password, @resource[:name], pkey, x509_cert, chain_certs) File.open(path, 'wb') { |f| f.print pkcs12.to_der } end # Keytool can only import a jceks keystore if the format is der. Generating and # importing a keystore is used to add private_key and certificate pairs. def to_der(path) x509_cert = OpenSSL::X509::Certificate.new File.read certificate File.open(path, 'wb') { |f| f.print x509_cert.to_der } end def get_chain(path) chain_certs = File.read(path, encoding: 'ISO-8859-1').scan(%r{-----BEGIN [^\n]*CERTIFICATE.*?-----END [^\n]*CERTIFICATE-----}m) if chain_certs.any? chain_certs.map { |cert| OpenSSL::X509::Certificate.new cert } else chain_certs << ((OpenSSL::X509::Certificate.new File.binread path)) end end def password if @resource[:password_file].nil? @resource[:password] else file = File.open(@resource[:password_file], 'r') pword = file.read file.close pword.chomp end end def password_file pword = password source_pword = sourcepassword tmpfile = Tempfile.new("#{@resource[:name]}.") contents = if File.exist?(@resource[:target]) && !File.zero?(@resource[:target]) if !source_pword.nil? "#{pword}\n#{source_pword}" else "#{pword}\n#{pword}" end elsif !source_pword.nil? "#{pword}\n#{pword}\n#{source_pword}" else "#{pword}\n#{pword}\n#{pword}" end tmpfile.write(contents) tmpfile.flush tmpfile end # Where we actually to the import of the file created using to_pkcs12. def import_ks tmppk12 = Tempfile.new("#{@resource[:name]}.") to_pkcs12(tmppk12.path) cmd = [ command_keytool, '-importkeystore', '-srcstoretype', 'PKCS12', '-destkeystore', @resource[:target], '-srckeystore', tmppk12.path, '-alias', @resource[:name] ] cmd << '-trustcacerts' if @resource[:trustcacerts] == :true cmd += ['-destkeypass', @resource[:destkeypass]] unless @resource[:destkeypass].nil? pwfile = password_file run_command(cmd, @resource[:target], pwfile) tmppk12.close! pwfile.close! if pwfile.is_a? Tempfile end def import_pkcs12 cmd = [ command_keytool, '-importkeystore', '-srcstoretype', 'PKCS12', '-destkeystore', @resource[:target], '-srckeystore', certificate ] if @resource[:source_alias] cmd.concat([ '-srcalias', @resource[:source_alias], '-destalias', @resource[:name] ]) end if @resource[:destkeypass] cmd.concat([ '-destkeypass', @resource[:destkeypass] ]) end pwfile = password_file run_command(cmd, @resource[:target], pwfile) pwfile.close! if pwfile.is_a? Tempfile end def import_jceks tmpder = Tempfile.new("#{@resource[:name]}.") to_der(tmpder.path) cmd = [ command_keytool, '-importcert', '-noprompt', '-alias', @resource[:name], '-file', tmpder.path, '-keystore', @resource[:target], '-storetype', storetype ] cmd << '-trustcacerts' if @resource[:trustcacerts] == :true cmd += ['-destkeypass', @resource[:destkeypass]] unless @resource[:destkeypass].nil? pwfile = password_file run_command(cmd, @resource[:target], pwfile) pwfile.close! if pwfile.is_a? Tempfile end def exists? cmd = [ command_keytool, '-list', '-keystore', @resource[:target], '-alias', @resource[:name] ] cmd += ['-storetype', storetype] if storetype == :jceks begin tmpfile = password_file run_command(cmd, false, tmpfile) tmpfile.close! return true rescue => e if e.message =~ %r{password was incorrect}i # we have the wrong password for the keystore. so delete it if :password_fail_reset if @resource[:password_fail_reset] == :true File.delete(@resource[:target]) end end return false end end # Extracts the fingerprints of a given output def extract_fingerprint(output) output.scan(%r{Certificate fingerprints:\n\s+(?:MD5: .*\n\s+)?SHA1: (.*)}).flatten.join('/') end # Reading the fingerprint of the certificate on disk. def latest # The certificate file may not exist during a puppet noop run as it's managed by puppet. # Return value must be different to provider.current to signify a possible trigger event. if Puppet[:noop] && !File.exist?(certificate) 'latest' elsif storetype == :pkcs12 cmd = [ command_keytool, '-v', '-list', '-keystore', certificate, '-storetype', 'PKCS12', '-storepass', sourcepassword ] output = run_command(cmd) latest = extract_fingerprint(output) latest else cmd = [ command_keytool, '-v', '-printcert', '-file', certificate ] output = run_command(cmd) if chain cmd = [ command_keytool, '-v', '-printcert', '-file', chain ] output += run_command(cmd) end latest = extract_fingerprint(output) latest end end # Reading the fingerprint of the certificate currently in the keystore. def current # The keystore file may not exist during a puppet noop run as it's managed by puppet. if Puppet[:noop] && !File.exist?(@resource[:target]) 'current' else cmd = [ command_keytool, '-list', '-v', '-keystore', @resource[:target], '-alias', @resource[:name] ] cmd += ['-storetype', storetype] if storetype == 'jceks' tmpfile = password_file output = run_command(cmd, false, tmpfile) tmpfile.close! current = extract_fingerprint(output) current end end # Determine if we need to do an import of a private_key and certificate pair # or just add a signed certificate, then do it. def create if !certificate.nil? && !private_key.nil? import_ks elsif certificate.nil? && !private_key.nil? raise Puppet::Error, 'Keytool is not capable of importing a private key without an accompanying certificate.' elsif storetype == :jceks import_jceks elsif storetype == :pkcs12 import_pkcs12 else cmd = [ command_keytool, '-importcert', '-noprompt', '-alias', @resource[:name], '-file', certificate, '-keystore', @resource[:target] ] cmd << '-trustcacerts' if @resource[:trustcacerts] == :true tmpfile = password_file run_command(cmd, @resource[:target], tmpfile) tmpfile.close! end end def destroy cmd = [ command_keytool, '-delete', '-alias', @resource[:name], '-keystore', @resource[:target] ] + cmd += ['-storetype', storetype] if storetype == :jceks tmpfile = password_file run_command(cmd, false, tmpfile) tmpfile.close! end # Being safe since I have seen some additions overwrite and some just throw errors. def update destroy create end def certificate @resource[:certificate] end def private_key @resource[:private_key] end def private_key_type @resource[:private_key_type] end def chain @resource[:chain] end def sourcepassword @resource[:source_password] end def storetype @resource[:storetype] end def run_command(cmd, target = false, stdinfile = false, env = {}) env[:PATH] = @resource[:path].join(File::PATH_SEPARATOR) if resource[:path] # The Puppet::Util::Execution.execute method is deprecated in Puppet 3.x # but we need this to work on 2.7.x too. exec_method = if Puppet::Util::Execution.respond_to?(:execute) Puppet::Util::Execution.method(:execute) else Puppet::Util.method(:execute) end withenv = if Puppet::Util::Execution.respond_to?(:withenv) Puppet::Util::Execution.method(:withenv) else Puppet::Util.method(:withenv) end # the java keytool will not correctly deal with an empty target keystore # file. If we encounter an empty keystore target file, preserve the mode, # owner and group, temporarily raise the umask, and delete the empty file. if target && (File.exist?(target) && File.zero?(target)) stat = File.stat(target) umask = File.umask(0o077) File.delete(target) end # There's a problem in IBM java keytool wherein stdin cannot be used # (trivially) to pass in the keystore passwords. The below hack makes the # provider work on SLES with minimal effort at the cost of letting the # passphrase to the keystore show up in the process list as an argument. # From a best practice standpoint the keystore should be protected by file # permissions and not just the passphrase so "making it work on SLES" # trumps. if Facter.value('osfamily') == 'Suse' && @resource[:password] cmd_to_run = cmd.is_a?(String) ? cmd.split(%r{\s}).first : cmd.first if cmd_to_run == command_keytool cmd << '-srcstorepass' << @resource[:password] cmd << '-deststorepass' << @resource[:password] end end # Now run the command options = { failonfail: true, combine: true } output = nil begin Timeout.timeout(@resource[:keytool_timeout], Timeout::Error) do output = if stdinfile withenv.call(env) do exec_method.call(cmd, options.merge(stdinfile: stdinfile.path)) end else withenv.call(env) do exec_method.call(cmd, options) end end end rescue Timeout::Error raise Puppet::Error, "Timed out waiting for '#{@resource[:name]}' to run keytool" end # for previously empty files, restore the umask, mode, owner and group. # The funky double-take check is because on Suse defined? doesn't seem # to behave quite the same as on Debian, RedHat if target and (defined? stat and stat) # rubocop:disable Style/AndOr : Changing 'and' to '&&' causes test failures. File.umask(umask) # Need to change group ownership before mode to prevent making the file # accessible to the wrong group. File.chown(stat.uid, stat.gid, target) File.chmod(stat.mode, target) end output end end diff --git a/spec/unit/puppet/provider/java_ks/keytool_spec.rb b/spec/unit/puppet/provider/java_ks/keytool_spec.rb index df305ed..f200d28 100644 --- a/spec/unit/puppet/provider/java_ks/keytool_spec.rb +++ b/spec/unit/puppet/provider/java_ks/keytool_spec.rb @@ -1,221 +1,221 @@ #!/usr/bin/env rspec require 'spec_helper' describe Puppet::Type.type(:java_ks).provider(:keytool) do let(:temp_dir) do if Puppet.features.microsoft_windows? ENV['TEMP'] else '/tmp/' end end let(:global_params) do { title: "app.example.com:#{temp_dir}application.jks", name: 'app.example.com', target: "#{temp_dir}application.jks", password: 'puppet', certificate: "#{temp_dir}app.example.com.pem", private_key: "#{temp_dir}private/app.example.com.pem", storetype: 'jceks', provider: described_class.name, } end let(:params) do global_params end let(:resource) do Puppet::Type.type(:java_ks).new(params) end let(:provider) do resource.provider end before(:each) do allow(provider).to receive(:command).with(:keytool).and_return('mykeytool') allow(provider).to receive(:command).with(:openssl).and_return('myopenssl') allow(provider).to receive(:command_keytool).and_return('mykeytool') allow(provider).to receive(:command_openssl).and_return('myopenssl') tempfile = class_double('tempfile', class: Tempfile, write: true, flush: true, close!: true, path: "#{temp_dir}testing.stuff") allow(Tempfile).to receive(:new).and_return(tempfile) end describe 'when updating a certificate' do it 'calls destroy and create' do expect(provider).to receive(:destroy) expect(provider).to receive(:create) provider.update end end describe 'when running keystore commands', if: !Puppet.features.microsoft_windows? do it 'calls the passed command' do cmd = '/bin/echo testing 1 2 3' exec_class = if Puppet::Util::Execution.respond_to?(:execute) Puppet::Util::Execution else Puppet::Util end expect(exec_class).to receive(:execute).with( cmd, failonfail: true, combine: true, ) provider.run_command(cmd) end context 'short timeout' do let(:params) do global_params.merge(keytool_timeout: 0.1) end it 'errors if timeout occurs' do cmd = 'sleep 1' expect { provider.run_command(cmd) }.to raise_error Puppet::Error, "Timed out waiting for 'app.example.com' to run keytool" end end it 'normally times out after 120 seconds' do cmd = '/bin/echo testing 1 2 3' expect(Timeout).to receive(:timeout).with(120, Timeout::Error).and_raise(Timeout::Error) expect { provider.run_command(cmd) }.to raise_error Puppet::Error, "Timed out waiting for 'app.example.com' to run keytool" end end describe 'when importing a private key and certifcate' do describe '#to_pkcs12' do it 'converts a certificate to a pkcs12 file' do sleep 0.1 # due to https://github.com/mitchellh/vagrant/issues/5056 testing_key = OpenSSL::PKey::RSA.new 1024 testing_ca = OpenSSL::X509::Certificate.new testing_ca.serial = 1 testing_ca.public_key = testing_key.public_key testing_subj = '/CN=Test CA/ST=Denial/L=Springfield/O=Dis/CN=www.example.com' testing_ca.subject = OpenSSL::X509::Name.parse testing_subj testing_ca.issuer = testing_ca.subject testing_ca.not_before = Time.now testing_ca.not_after = testing_ca.not_before + 360 testing_ca.sign(testing_key, OpenSSL::Digest::SHA256.new) allow(provider).to receive(:password).and_return(resource[:password]) allow(File).to receive(:read).with(resource[:private_key]).and_return('private key') allow(File).to receive(:read).with(resource[:certificate], hash_including(encoding: 'ISO-8859-1')).and_return(testing_ca.to_pem) expect(OpenSSL::PKey::RSA).to receive(:new).with('private key', 'puppet').and_return('priv_obj') expect(OpenSSL::X509::Certificate).to receive(:new).with(testing_ca.to_pem.chomp).and_return('cert_obj') pkcs_double = BogusPkcs.new expect(pkcs_double).to receive(:to_der) expect(OpenSSL::PKCS12).to receive(:create).with(resource[:password], resource[:name], 'priv_obj', 'cert_obj', []).and_return(pkcs_double) provider.to_pkcs12("#{temp_dir}testing.stuff") end end describe '#import_ks' do it 'executes openssl and keytool with specific options' do expect(provider).to receive(:to_pkcs12).with("#{temp_dir}testing.stuff") expect(provider).to receive(:run_command).with(['mykeytool', '-importkeystore', '-srcstoretype', 'PKCS12', '-destkeystore', resource[:target], '-srckeystore', "#{temp_dir}testing.stuff", '-alias', resource[:name]], any_args) provider.import_ks end it 'uses destkeypass when provided' do dkp = resource.dup dkp[:destkeypass] = 'keypass' expect(provider).to receive(:to_pkcs12).with("#{temp_dir}testing.stuff") expect(provider).to receive(:run_command).with(['mykeytool', '-importkeystore', '-srcstoretype', 'PKCS12', '-destkeystore', dkp[:target], '-srckeystore', "#{temp_dir}testing.stuff", '-alias', dkp[:name], '-destkeypass', dkp[:destkeypass]], any_args) provider.import_ks end end end describe 'when importing a pkcs12 file' do let(:params) do { title: "app.example.com:#{temp_dir}testing.jks", name: 'app.example.com', target: "#{temp_dir}application.jks", password: 'puppet', certificate: "#{temp_dir}testing.p12", storetype: 'pkcs12', source_password: 'password', provider: described_class.name, } end let(:resource) do Puppet::Type.type(:java_ks).new(params) end let(:provider) do resource.provider end describe '#import_pkcs12' do it 'supports pkcs12 source' do pkcs12 = resource.dup pkcs12[:storetype] = 'pkcs12' expect(provider).to receive(:run_command).with(['mykeytool', '-importkeystore', '-srcstoretype', 'PKCS12', '-destkeystore', pkcs12[:target], '-srckeystore', "#{temp_dir}testing.p12"], any_args) provider.import_pkcs12 end end end describe 'when creating entries in a keystore' do let(:params) do { title: "app.example.com:#{temp_dir}application.jks", name: 'app.example.com', target: "#{temp_dir}application.jks", password: 'puppet', certificate: "#{temp_dir}app.example.com.pem", private_key: "#{temp_dir}private/app.example.com.pem", provider: described_class.name, } end let(:resource) do Puppet::Type.type(:java_ks).new(params) end let(:provider) do resource.provider end it 'calls import_ks if private_key and certificate are provided' do expect(provider).to receive(:import_ks) provider.create end it 'calls keytool with specific options if only certificate is provided' do no_pk = resource.dup no_pk.delete(:private_key) expect(provider).to receive(:run_command).with(['mykeytool', '-importcert', '-noprompt', '-alias', no_pk[:name], '-file', no_pk[:certificate], '-keystore', no_pk[:target]], any_args) expect(no_pk.provider).to receive(:import_ks).never no_pk.provider.create end end describe 'when removing entries from keytool' do it 'executes keytool with a specific set of options' do - expect(provider).to receive(:run_command).with(['mykeytool', '-delete', '-alias', resource[:name], '-keystore', resource[:target]], any_args) + expect(provider).to receive(:run_command).with(['mykeytool', '-delete', '-alias', resource[:name], '-keystore', resource[:target], '-storetype', resource[:storetype]], any_args) provider.destroy end end end class BogusPkcs end