diff --git a/lib/puppet/provider/java_ks/keytool.rb b/lib/puppet/provider/java_ks/keytool.rb index e335beb..abf45ff 100644 --- a/lib/puppet/provider/java_ks/keytool.rb +++ b/lib/puppet/provider/java_ks/keytool.rb @@ -1,375 +1,375 @@ # frozen_string_literal: true 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 + true rescue => e if e.message.match?(%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 + 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/type/java_ks_spec.rb b/spec/unit/puppet/type/java_ks_spec.rb index 7b0d222..143427d 100644 --- a/spec/unit/puppet/type/java_ks_spec.rb +++ b/spec/unit/puppet/type/java_ks_spec.rb @@ -1,245 +1,245 @@ # frozen_string_literal: true ! # /usr/bin/env rspec require 'spec_helper' describe Puppet::Type.type(:java_ks) do let(:temp_dir) do if Puppet.features.microsoft_windows? ENV['TEMP'] else '/tmp/' end end let(:provider_var) { class_double('provider', class: described_class.defaultprovider, clear: nil) } let(:app_example_com) do { title: "app.example.com:#{temp_dir}application.jks", name: 'app.example.com', target: "#{temp_dir}application.jks", password: 'puppet', destkeypass: 'keypass', certificate: "#{temp_dir}app.example.com.pem", private_key: "#{temp_dir}private/app.example.com.pem", private_key_type: 'rsa', storetype: 'jceks', provider: :keytool, } end let(:jks_resource) do app_example_com end before(:each) do allow(described_class.defaultprovider).to receive(:new).and_return(provider_var) end it 'defaults to being present' do expect(described_class.new(app_example_com)[:ensure]).to eq(:present) end describe 'when validating attributes' do [:name, :target, :private_key, :private_key_type, :certificate, :password, :password_file, :trustcacerts, :destkeypass, :password_fail_reset, :source_password].each do |param| - it "should have a #{param} parameter" do + it "has a #{param} parameter" do expect(described_class.attrtype(param)).to eq(:param) end end [:ensure].each do |prop| - it "should have a #{prop} property" do + it "has a #{prop} property" do expect(described_class.attrtype(prop)).to eq(:property) end end end describe 'when validating attribute values' do [:present, :absent, :latest].each do |value| - it "should support #{value} as a value to ensure" do + it "supports #{value} as a value to ensure" do described_class.new(jks_resource.merge(ensure: value)) end end it 'first half of title should map to name parameter' do jks = jks_resource.dup jks.delete(:name) expect(described_class.new(jks)[:name]).to eq(jks_resource[:name]) end it 'second half of title should map to target parameter when no target is supplied' do jks = jks_resource.dup jks.delete(:target) expect(described_class.new(jks)[:target]).to eq(jks_resource[:target]) end it 'second half of title should not map to target parameter when target is supplied #not to equal' do jks = jks_resource.dup jks[:target] = "#{temp_dir}some_other_app.jks" expect(described_class.new(jks)[:target]).not_to eq(jks_resource[:target]) end it 'second half of title should not map to target parameter when target is supplied #to equal' do jks = jks_resource.dup jks[:target] = "#{temp_dir}some_other_app.jks" expect(described_class.new(jks)[:target]).to eq("#{temp_dir}some_other_app.jks") end it 'title components should map to namevar parameters #name' do jks = jks_resource.dup jks.delete(:name) jks.delete(:target) expect(described_class.new(jks)[:name]).to eq(jks_resource[:name]) end it 'title components should map to namevar parameters #target' do jks = jks_resource.dup jks.delete(:name) jks.delete(:target) expect(described_class.new(jks)[:target]).to eq(jks_resource[:target]) end it 'downcases :name values' do jks = jks_resource.dup jks[:name] = 'APP.EXAMPLE.COM' expect(described_class.new(jks)[:name]).to eq(jks_resource[:name]) end it 'has :false value to :trustcacerts when parameter not provided' do expect(described_class.new(jks_resource)[:trustcacerts]).to eq(:false) end it 'has :rsa as the default value for :private_key_type' do expect(described_class.new(jks_resource)[:private_key_type]).to eq(:rsa) end it 'fails if :private_key_type is neither :rsa nor :ec nor :dsa' do jks = jks_resource.dup jks[:private_key_type] = 'nosuchkeytype' expect { described_class.new(jks) }.to raise_error(Puppet::Error) end it 'fails if both :password and :password_file are provided' do jks = jks_resource.dup jks[:password_file] = '/path/to/password_file' expect { described_class.new(jks) }.to raise_error(Puppet::Error, %r{You must pass either}) end it 'fails if neither :password or :password_file is provided' do jks = jks_resource.dup jks.delete(:password) expect { described_class.new(jks) }.to raise_error(Puppet::Error, %r{You must pass one of}) end it 'fails if :password is fewer than 6 characters' do jks = jks_resource.dup jks[:password] = 'aoeui' expect { described_class.new(jks) }.to raise_error(Puppet::Error, %r{6 characters}) end it 'fails if :destkeypass is fewer than 6 characters' do jks = jks_resource.dup jks[:destkeypass] = 'aoeui' expect { described_class.new(jks) }.to raise_error(Puppet::Error, %r{length 6}) end it 'has :false value to :password_fail_reset when parameter not provided' do expect(described_class.new(jks_resource)[:password_fail_reset]).to eq(:false) end it 'fails if :source_password is not provided for pkcs12 :storetype' do jks = jks_resource.dup jks[:storetype] = 'pkcs12' expect { described_class.new(jks) }.to raise_error(Puppet::Error, %r{You must provide 'source_password' when using a 'pkcs12' storetype}) end end describe 'when ensure is set to latest' do it 'insync? should return false if sha1 fingerprints do not match and state is :present' do jks = jks_resource.dup jks[:ensure] = :latest allow(provider_var).to receive(:latest).and_return('9B:8B:23:4C:6A:9A:08:F6:4E:B6:01:23:EA:5A:E7:8F:6A') allow(provider_var).to receive(:current).and_return('21:46:45:65:57:50:FE:2D:DA:7C:C8:57:D2:33:3A:B0:A6') expect(described_class.new(jks).property(:ensure)).not_to be_insync(:present) end it 'insync? should return false if state is :absent' do jks = jks_resource.dup jks[:ensure] = :latest expect(described_class.new(jks).property(:ensure)).not_to be_insync(:absent) end it 'insync? should return true if sha1 fingerprints match and state is :present' do jks = jks_resource.dup jks[:ensure] = :latest allow(provider_var).to receive(:latest).and_return('66:9B:8B:23:4C:6A:9A:08:F6:4E:B6:01:23:EA:5A') allow(provider_var).to receive(:current).and_return('66:9B:8B:23:4C:6A:9A:08:F6:4E:B6:01:23:EA:5A') expect(described_class.new(jks).property(:ensure)).to be_insync(:present) end end describe 'when file resources are in the catalog' do let(:file_provider) { class_double('provider', class: Puppet::Type.type(:file).defaultprovider, clear: nil) } before(:each) do allow(Puppet::Type.type(:file).defaultprovider).to receive(:new).and_return(file_provider) end [:private_key, :certificate].each do |file| - it "should autorequire for #{file} #file" do + it "autorequires for #{file} #file" do test_jks = described_class.new(jks_resource) test_file = Puppet::Type.type(:file).new(title: jks_resource[file]) Puppet::Resource::Catalog.new :testing do |conf| [test_jks, test_file].each { |resource| conf.add_resource resource } end rel = test_jks.autorequire[0] expect(rel.source.ref).to eq(test_file.ref) end - it "should autorequire for #{file} #jks" do + it "autorequires for #{file} #jks" do test_jks = described_class.new(jks_resource) test_file = Puppet::Type.type(:file).new(title: jks_resource[file]) Puppet::Resource::Catalog.new :testing do |conf| [test_jks, test_file].each { |resource| conf.add_resource resource } end rel = test_jks.autorequire[0] expect(rel.target.ref).to eq(test_jks.ref) end end it 'autorequires for the :target directory #file' do test_jks = described_class.new(jks_resource) test_file = Puppet::Type.type(:file).new(title: ::File.dirname(jks_resource[:target])) Puppet::Resource::Catalog.new :testing do |conf| [test_jks, test_file].each { |resource| conf.add_resource resource } end rel = test_jks.autorequire[0] expect(rel.source.ref).to eq(test_file.ref) end it 'autorequires for the :target directory #jks' do test_jks = described_class.new(jks_resource) test_file = Puppet::Type.type(:file).new(title: ::File.dirname(jks_resource[:target])) Puppet::Resource::Catalog.new :testing do |conf| [test_jks, test_file].each { |resource| conf.add_resource resource } end rel = test_jks.autorequire[0] expect(rel.target.ref).to eq(test_jks.ref) end end end