diff --git a/lib/puppet/parser/functions/pw_hash.rb b/lib/puppet/parser/functions/pw_hash.rb index 3351c9f..e66ae41 100644 --- a/lib/puppet/parser/functions/pw_hash.rb +++ b/lib/puppet/parser/functions/pw_hash.rb @@ -1,73 +1,87 @@ # frozen_string_literal: true # Please note: This function is an implementation of a Ruby class and as such may not be entirely UTF8 compatible. # To ensure compatibility please use this function with Ruby 2.4.0 or greater - https://bugs.ruby-lang.org/issues/10085. # Puppet::Parser::Functions.newfunction( :pw_hash, type: :rvalue, arity: 3, doc: <<-DOC, @summary Hashes a password using the crypt function. Provides a hash usable on most POSIX systems. The first argument to this function is the password to hash. If it is undef or an empty string, this function returns undef. The second argument to this function is which type of hash to use. It will be converted into the appropriate crypt(3) hash specifier. Valid hash types are: - |Hash type |Specifier| - |---------------------|---------| - |MD5 |1 | - |SHA-256 |5 | - |SHA-512 (recommended)|6 | + |Hash type|Prefix|Note | + |---------|------|--------------------| + |MD5 |1 | | + |SHA-256 |5 | | + |SHA-512 |6 |Recommended | + |bcrypt |2b | | + |bcrypt-a |2a |bug compatible | + |bcrypt-x |2x |bug compatible | + |bcrypt-y |2y |historic alias to 2b| The third argument to this function is the salt to use. @return [Hash] Provides a hash usable on most POSIX systems. > *Note:*: this uses the Puppet Server's implementation of crypt(3). If your environment contains several different operating systems, ensure that they are compatible before using this function. DOC ) do |args| raise ArgumentError, "pw_hash(): wrong number of arguments (#{args.size} for 3)" if args.size != 3 args.map! do |arg| if (defined? Puppet::Pops::Types::PSensitiveType::Sensitive) && (arg.is_a? Puppet::Pops::Types::PSensitiveType::Sensitive) arg.unwrap else arg end end + + hashes = { + 'md5' => { :prefix => '1' }, + 'sha-256' => { :prefix => '5' }, + 'sha-512' => { :prefix => '6' }, + 'bcrypt' => { :prefix => '2b', :salt => %r{^[0-9]{2}\$[./A-Za-z0-9]{22}} }, + 'bcrypt-a' => { :prefix => '2a', :salt => %r{^[0-9]{2}\$[./A-Za-z0-9]{22}} }, + 'bcrypt-x' => { :prefix => '2x', :salt => %r{^[0-9]{2}\$[./A-Za-z0-9]{22}} }, + 'bcrypt-y' => { :prefix => '2y', :salt => %r{^[0-9]{2}\$[./A-Za-z0-9]{22}} }, + } + raise ArgumentError, 'pw_hash(): first argument must be a string' unless args[0].is_a?(String) || args[0].nil? raise ArgumentError, 'pw_hash(): second argument must be a string' unless args[1].is_a? String - hashes = { 'md5' => '1', - 'sha-256' => '5', - 'sha-512' => '6' } hash_type = hashes[args[1].downcase] raise ArgumentError, "pw_hash(): #{args[1]} is not a valid hash type" if hash_type.nil? raise ArgumentError, 'pw_hash(): third argument must be a string' unless args[2].is_a? String raise ArgumentError, 'pw_hash(): third argument must not be empty' if args[2].empty? - raise ArgumentError, 'pw_hash(): characters in salt must be in the set [a-zA-Z0-9./]' unless %r{\A[a-zA-Z0-9./]+\z}.match?(args[2]) + salt_doc = hash_type.include?(:salt) ? "match #{hash_type[:salt]}" : 'be in the set [a-zA-Z0-9./]' + salt_regex = hash_type.fetch(:salt, %r{\A[a-zA-Z0-9./]+\z}) + raise ArgumentError, "pw_hash(): characters in salt must #{salt_doc}" unless salt_regex.match?(args[2]) password = args[0] return nil if password.nil? || password.empty? - salt = "$#{hash_type}$#{args[2]}" + salt = "$#{hash_type[:prefix]}$#{args[2]}" # handle weak implementations of String#crypt # dup the string to get rid of frozen status for testing if ('test'.dup).crypt('$1$1') != '$1$1$Bp8CU9Oujr9SSEw53WV6G.' # JRuby < 1.7.17 # MS Windows and other systems that don't support enhanced salts raise Puppet::ParseError, 'system does not support enhanced salts' unless RUBY_PLATFORM == 'java' # puppetserver bundles Apache Commons Codec org.apache.commons.codec.digest.Crypt.crypt(password.to_java_bytes, salt) else password.crypt(salt) end end diff --git a/spec/functions/pw_hash_spec.rb b/spec/functions/pw_hash_spec.rb index ac7d097..be85678 100644 --- a/spec/functions/pw_hash_spec.rb +++ b/spec/functions/pw_hash_spec.rb @@ -1,81 +1,88 @@ # frozen_string_literal: true require 'spec_helper' describe 'pw_hash' do it { is_expected.not_to eq(nil) } context 'when there are less than 3 arguments' do it { is_expected.to run.with_params.and_raise_error(ArgumentError, %r{wrong number of arguments}i) } it { is_expected.to run.with_params('password').and_raise_error(ArgumentError, %r{wrong number of arguments}i) } it { is_expected.to run.with_params('password', 'sha-256').and_raise_error(ArgumentError, %r{wrong number of arguments}i) } end context 'when there are more than 3 arguments' do it { is_expected.to run.with_params('password', 'sha-256', 'salt', 'extra').and_raise_error(ArgumentError, %r{wrong number of arguments}i) } it { is_expected.to run.with_params('password', 'sha-256', 'salt', 'extra', 'extra').and_raise_error(ArgumentError, %r{wrong number of arguments}i) } end context 'when the first argument is not a string' do it { is_expected.to run.with_params([], 'sha-256', 'salt').and_raise_error(ArgumentError, %r{first argument must be a string}) } it { is_expected.to run.with_params({}, 'sha-256', 'salt').and_raise_error(ArgumentError, %r{first argument must be a string}) } it { is_expected.to run.with_params(1, 'sha-256', 'salt').and_raise_error(ArgumentError, %r{first argument must be a string}) } it { is_expected.to run.with_params(true, 'sha-256', 'salt').and_raise_error(ArgumentError, %r{first argument must be a string}) } end context 'when the first argument is undefined' do it { is_expected.to run.with_params('', 'sha-256', 'salt').and_return(nil) } it { is_expected.to run.with_params(nil, 'sha-256', 'salt').and_return(nil) } end context 'when the second argument is not a string' do it { is_expected.to run.with_params('password', [], 'salt').and_raise_error(ArgumentError, %r{second argument must be a string}) } it { is_expected.to run.with_params('password', {}, 'salt').and_raise_error(ArgumentError, %r{second argument must be a string}) } it { is_expected.to run.with_params('password', 1, 'salt').and_raise_error(ArgumentError, %r{second argument must be a string}) } it { is_expected.to run.with_params('password', true, 'salt').and_raise_error(ArgumentError, %r{second argument must be a string}) } end context 'when the second argument is not one of the supported hashing algorithms' do it { is_expected.to run.with_params('password', 'no such algo', 'salt').and_raise_error(ArgumentError, %r{is not a valid hash type}) } end context 'when the third argument is not a string' do it { is_expected.to run.with_params('password', 'sha-256', []).and_raise_error(ArgumentError, %r{third argument must be a string}) } it { is_expected.to run.with_params('password', 'sha-256', {}).and_raise_error(ArgumentError, %r{third argument must be a string}) } it { is_expected.to run.with_params('password', 'sha-256', 1).and_raise_error(ArgumentError, %r{third argument must be a string}) } it { is_expected.to run.with_params('password', 'sha-256', true).and_raise_error(ArgumentError, %r{third argument must be a string}) } end context 'when the third argument is empty' do it { is_expected.to run.with_params('password', 'sha-512', '').and_raise_error(ArgumentError, %r{third argument must not be empty}) } end context 'when the third argument contains invalid characters' do it { is_expected.to run.with_params('password', 'sha-512', 'one%').and_raise_error(ArgumentError, %r{characters in salt must be in the set}) } + it { is_expected.to run.with_params('password', 'bcrypt', '1234').and_raise_error(ArgumentError, %r{characters in salt must match}) } + end + + context 'when run' do + it { is_expected.to run.with_params('password', 'sha-512', '1234').and_return(%r{^\$6\$1234\$}) } + it { is_expected.to run.with_params('password', 'bcrypt', '05$abcdefghijklmnopqrstuv').and_return(%r{^\$2b\$05\$abcdefghijklmnopqrstu}) } + it { is_expected.to run.with_params('password', 'bcrypt-y', '05$abcdefghijklmnopqrstuv').and_return(%r{^\$2y\$05\$abcdefghijklmnopqrstu}) } end context 'when running on a platform with a weak String#crypt implementation' do before(:each) { allow_any_instance_of(String).to receive(:crypt).with('$1$1').and_return('a bad hash') } # rubocop:disable RSpec/AnyInstance : Unable to find a viable replacement it { is_expected.to run.with_params('password', 'sha-512', 'salt').and_raise_error(Puppet::ParseError, %r{system does not support enhanced salts}) } end if RUBY_PLATFORM == 'java' || 'test'.crypt('$1$1') == '$1$1$Bp8CU9Oujr9SSEw53WV6G.' describe 'on systems with enhanced salts support' do it { is_expected.to run.with_params('password', 'md5', 'salt').and_return('$1$salt$qJH7.N4xYta3aEG/dfqo/0') } it { is_expected.to run.with_params('password', 'sha-256', 'salt').and_return('$5$salt$Gcm6FsVtF/Qa77ZKD.iwsJlCVPY0XSMgLJL0Hnww/c1') } it { is_expected.to run.with_params('password', 'sha-512', 'salt').and_return('$6$salt$IxDD3jeSOb5eB1CX5LBsqZFVkJdido3OUILO5Ifz5iwMuTS4XMS130MTSuDDl3aCI6WouIL9AjRbLCelDCy.g.') } end if Puppet::Util::Package.versioncmp(Puppet.version, '4.7.0') >= 0 describe 'when arguments are sensitive' do it { is_expected.to run.with_params(Puppet::Pops::Types::PSensitiveType::Sensitive.new('password'), 'md5', 'salt').and_return('$1$salt$qJH7.N4xYta3aEG/dfqo/0') } it { is_expected.to run.with_params(Puppet::Pops::Types::PSensitiveType::Sensitive.new('password'), 'md5', Puppet::Pops::Types::PSensitiveType::Sensitive.new('salt')) .and_return('$1$salt$qJH7.N4xYta3aEG/dfqo/0') } it { is_expected.to run.with_params('password', 'md5', Puppet::Pops::Types::PSensitiveType::Sensitive.new('salt')).and_return('$1$salt$qJH7.N4xYta3aEG/dfqo/0') } end end end end