diff --git a/.github/workflows/rubocop.yml b/.github/workflows/rubocop.yml deleted file mode 100644 index 3deeaa7..0000000 --- a/.github/workflows/rubocop.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Rubocop - -# Run this workflow every time a new commit pushed to your repository -on: push - -jobs: - - rubocop: - name: Rubocopchecks - runs-on: ubuntu-latest - steps: - - name: Run Rubocop - uses: gimenete/rubocop-action@1.0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 9a531a9..632570d 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -1,21 +1,29 @@ name: Test Suite -on: push +# Run against all commits and pull requests. +on: [push, pull_request] jobs: - test: + test_matrix: runs-on: ubuntu-latest strategy: matrix: - ruby-version: ['3.0', 2.7, 2.5] + ruby-version: + - 2.5 + - 2.6 + - 2.7 + - 3.0 + + env: + TEST_CHECKS: 100 steps: - uses: actions/checkout@v2 - name: Set up Ruby ${{ matrix.ruby-version }} uses: ruby/setup-ruby@v1 with: - ruby-version: ${{ matrix.ruby-version }} + ruby-version: ${{ matrix.ruby-version }} - name: Install dependencies run: bundle install - name: Init submodules @@ -25,8 +33,37 @@ jobs: - name: Test C library run: cd ext/argon2_wrap/ && make test && cd ../.. - name: Run tests - run: bundle exec rake - - name: Coveralls + run: bundle exec rake test + - name: Coveralls Parallel uses: coverallsapp/github-action@master with: github-token: ${{ secrets.GITHUB_TOKEN }} + flag-name: run-${{ matrix.ruby-version }} + parallel: true + + rubocop: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.0 + - name: Install dependencies + run: bundle install + - name: Run rubocop + run: bundle exec rake rubocop + + finish: + runs-on: ubuntu-latest + needs: [test_matrix, rubocop] + steps: + - name: Coveralls Finished + uses: coverallsapp/github-action@master + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + parallel-finished: true + - name: Wait for status checks + run: echo "All Green!" diff --git a/Changelog.md b/Changelog.md index 5810c28..ccc394a 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,15 @@ +# Changelog + +## v3.0.0: TBD + +**Includes multiple breaking changes, see [README.md](README.md) for migration +instructions.** + +- Refactored Argon2::Password to include additional helpers and simplify hash + creation. +- Renamed top level exception from: `Argon2::ArgonHashHail` to: `Argon2::Error` +- Added new exceptions that inherit from the top level exception. + ## v2.0.3: 2021-01-02 - Address potential memory leak. Unlikely to be exploitable. diff --git a/README.md b/README.md index 263c3ea..5014256 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,9 @@ argon = Argon2::Password.new(t_cost: 2, m_cost: 16, secret: KEY) myhash = argon.create("A password") Argon2::Password.verify_password("A password", myhash, KEY) ``` + ## Ruby 3 Types + I am now shipping signatures in sig/. The following command sets up a testing interface. ```sh RBS_TEST_TARGET="Argon2::*" bundle exec ruby -r rbs/test/setup bin/console @@ -83,6 +85,45 @@ steep check ``` These tools will need to be installed manually at this time and will be added to Gemfiles after much further testing. +## Version 3.0 - Breaking API changes, addition of new helper functions + +### Argon2::Password API refactored + +**Argon2::Password.new and Argon2::Password.create are now different.** + +Argon2::Passwords can now be created without initializing an instance first. + +```ruby +# Take instances where we abstract creating the password by first exposing an Object instance +instance = Argon2::Password.new(m_cost: some_m_cost) +instance.create(input_password) + +# And remove the abstraction step +Argon2::Password.create(input_password, m_cost: some_m_cost) +``` + +### Errors restructured + +**The root level error for Argon2 has been renamed.** + +Argon2::ArgonHashFail has been renamed to Argon2::Error + +```ruby +# Find any instances of Argon2::ArgonHashFail, for example... +def login(username, password) + [...] +rescue Argon2::ArgonHashFail + [...] +end + +# And do a straight 1:1 replacement +def login(username, password) + [...] +rescue Argon2::Error + [...] +end +``` + ## Version 2.0 - Argon 2id Version 2.x upwards will now default to the Argon2id hash format. This is consistent with current recommendations regarding Argon2 usage. It remains capable of verifying existing hashes. diff --git a/Rakefile b/Rakefile index 98db72a..374cf29 100644 --- a/Rakefile +++ b/Rakefile @@ -13,4 +13,4 @@ Rake::TestTask.new(:test) do |t| t.test_files = FileList['test/**/*_test.rb'] end -task :default => :test +task :default => %i[test rubocop] diff --git a/Steepfile b/Steepfile index 01dfa43..981ab7f 100644 --- a/Steepfile +++ b/Steepfile @@ -1,3 +1,5 @@ +# frozen_string_literal: true + target :lib do signature "sig" diff --git a/argon2.gemspec b/argon2.gemspec index a965d65..5eea77d 100644 --- a/argon2.gemspec +++ b/argon2.gemspec @@ -16,7 +16,14 @@ Gem::Specification.new do |spec| spec.homepage = 'https://github.com/technion/ruby-argon2' spec.license = 'MIT' - spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } + # TODO: Remove post install message after `v3.0.0` is released. + spec.post_install_message = + 'Version 3.0 of the Argon2 ruby wrapper includes breaking changes on the '\ + 'usage/generation of passwords. If you use Argon2 directly, you will need '\ + 'to follow the "migrating to v3" guide in the project README. Otherwise '\ + 'you can safely ignore this message.' + + spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } spec.files << `find ext`.split spec.bindir = "exe" diff --git a/lib/argon2.rb b/lib/argon2.rb index 2d3ba7c..f912647 100644 --- a/lib/argon2.rb +++ b/lib/argon2.rb @@ -1,48 +1,17 @@ # frozen_string_literal: true +## +# This Ruby Gem provides FFI bindings and a simplified interface to the Argon2 +# algorithm. Argon2 is the official winner of the Password Hashing Competition, +# a several year project to identify a successor to bcrypt/PBKDF/scrypt methods +# of securely storing passwords. This is an independent project and not official +# from the PHC team. +# +module Argon2; end + require 'argon2/constants' require 'argon2/ffi_engine' require 'argon2/version' require 'argon2/errors' require 'argon2/engine' - -module Argon2 - # Front-end API for the Argon2 module. - class Password - def initialize(options = {}) - @t_cost = options[:t_cost] || 2 - raise ArgonHashFail, "Invalid t_cost" if @t_cost < 1 || @t_cost > 750 - - @m_cost = options[:m_cost] || 16 - raise ArgonHashFail, "Invalid m_cost" if @m_cost < 1 || @m_cost > 31 - - @salt = options[:salt_do_not_supply] || Engine.saltgen - @secret = options[:secret] - end - - def create(pass) - raise ArgonHashFail, "Invalid password (expected string)" unless - pass.is_a?(String) - - Argon2::Engine.hash_argon2id_encode( - pass, @salt, @t_cost, @m_cost, @secret) - end - - # Helper class, just creates defaults and calls hash() - def self.create(pass) - argon2 = Argon2::Password.new - argon2.create(pass) - end - - # Supports 1 and argon2id formats. - def self.valid_hash?(hash) - /^\$argon2(id?|d).{,113}/ =~ hash - end - - def self.verify_password(pass, hash, secret = nil) - raise ArgonHashFail, "Invalid hash" unless valid_hash?(hash) - - Argon2::Engine.argon2_verify(pass, hash, secret) - end - end -end +require 'argon2/password' diff --git a/lib/argon2/constants.rb b/lib/argon2/constants.rb index daafa93..a80b94c 100644 --- a/lib/argon2/constants.rb +++ b/lib/argon2/constants.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true module Argon2 + ## # Constants utilised in several parts of the Argon2 module - # SALT_LEN is a standard recommendation from the Argon2 spec. - + # module Constants - SALT_LEN = 16 - OUT_LEN = 32 # Binary, unencoded output + SALT_LEN = 16 # Standard recommendation from the Argon2 spec + OUT_LEN = 32 # Binary, unencoded output ENCODE_LEN = 108 # Encoded output end end diff --git a/lib/argon2/engine.rb b/lib/argon2/engine.rb index 06e23fc..62087ab 100644 --- a/lib/argon2/engine.rb +++ b/lib/argon2/engine.rb @@ -3,8 +3,14 @@ require 'securerandom' module Argon2 - # Generates a random, binary string for use as a salt. + ## + # The engine class shields users from the FFI interface. + # It is generally not advised to directly use this class. + # class Engine + ## + # Generates a random, binary string for use as a salt. + # def self.saltgen SecureRandom.random_bytes(Argon2::Constants::SALT_LEN) end diff --git a/lib/argon2/errors.rb b/lib/argon2/errors.rb index b64abdb..7e6729a 100644 --- a/lib/argon2/errors.rb +++ b/lib/argon2/errors.rb @@ -1,10 +1,87 @@ # frozen_string_literal: true -# Defines an array of errors that matches the enum list of errors from -# argon2.h. This allows return values to propagate errors through the FFI. - module Argon2 - class ArgonHashFail < StandardError; end + ## + # Generic error to catch anything the Argon2 ruby library throws. + # + class Error < StandardError; end + + ## + # Various errors for invalid parameters passed to the library. + # + module Errors + ## + # Raised when an invalid Argon2 hash has been passed to Argon2::Password.new + # + class InvalidHash < Argon2::Error; end + + ## + # Raised when a valid Argon2 hash was passed to Argon2::Password, but the + # version information is missing or corrupted. + # + class InvalidVersion < InvalidHash; end + + ## + # Abstract error class that isn't raised directly, but allows you to catch + # any cost error, regardless of which value was invalid. + # + class InvalidCost < InvalidHash; end + + ## + # Raised when an invalid time cost has been passed to + # Argon2::Password.create, or the hash passed to Argon2::Password.new + # was valid but the time cost information is missing or corrupted. + # + class InvalidTCost < InvalidCost; end + + ## + # Raised when an invalid memory cost has been passed to + # Argon2::Password.create, or the hash passed to Argon2::Password.new + # was valid but the memory cost information is missing or corrupted. + # + class InvalidMCost < InvalidCost; end + + ## + # Raised when an invalid parallelism cost has been passed to + # Argon2::Password.create, or the hash passed to Argon2::Password.new + # was valid but the parallelism cost information is missing or corrupted. + # + class InvalidPCost < InvalidCost; end + + ## + # Raised when a non-string object is passed to Argon2::Password.create + # + class InvalidPassword < Argon2::Error + def initialize(msg = "Invalid password (expected a String)") + super + end + end + + ## + # Raised when an invalid salt length was passed to + # Argon2::Engine.hash_argon2id_encode + # + class InvalidSaltSize < Argon2::Error; end + + ## + # Raised when the output length passed to Argon2::Engine.hash_argon2i or + # Argon2::Engine.hash_argon2id is invalid. + # + class InvalidOutputLength < Argon2::Error; end + + ## + # Error raised by/caught from the Argon2 C Library. + # + # See Argon2::ERRORS for a full list of related error codes. + # + class ExtError < Argon2::Error; end + end + + ## + # Defines an array of errors that matches the enum list of errors from + # argon2.h. This allows return values to propagate errors through the FFI. Any + # error from this list will be thrown as an Argon2::Errors::ExtError + # ERRORS = %w[ ARGON2_OK ARGON2_OUTPUT_PTR_NULL @@ -40,5 +117,5 @@ class ArgonHashFail < StandardError; end ARGON2_ENCODING_FAIL ARGON2_DECODING_FAIL ARGON2_THREAD_FAIL - ].freeze + ].freeze end diff --git a/lib/argon2/ffi_engine.rb b/lib/argon2/ffi_engine.rb index 7258042..9b98549 100644 --- a/lib/argon2/ffi_engine.rb +++ b/lib/argon2/ffi_engine.rb @@ -4,7 +4,10 @@ require 'ffi-compiler/loader' module Argon2 - # Direct external bindings. Call these methods via the Engine class to ensure points are dealt with + ## + # Direct external bindings. Call these methods via the Engine class to ensure + # points are dealt with. + # module Ext extend FFI::Library ffi_lib FFI::Compiler::Loader.find(FFI::Platform.windows? ? 'libargon2_wrap' : 'argon2_wrap') @@ -43,19 +46,21 @@ module Ext pointer size_t], :int, :blocking => true end + ## # The engine class shields users from the FFI interface. # It is generally not advised to directly use this class. + # class Engine def self.hash_argon2i(password, salt, t_cost, m_cost, out_len = nil) out_len = (out_len || Constants::OUT_LEN).to_i - raise ArgonHashFail, "Invalid output length" if out_len < 1 + raise ::Argon2::Errors::InvalidOutputLength if out_len < 1 result = '' FFI::MemoryPointer.new(:char, out_len) do |buffer| ret = Ext.argon2i_hash_raw(t_cost, 1 << m_cost, 1, password, password.length, salt, salt.length, buffer, out_len) - raise ArgonHashFail, ERRORS[ret.abs] unless ret.zero? + raise ::Argon2::Errors::ExtError, ERRORS[ret.abs] unless ret.zero? result = buffer.read_string(out_len) end @@ -64,14 +69,14 @@ def self.hash_argon2i(password, salt, t_cost, m_cost, out_len = nil) def self.hash_argon2id(password, salt, t_cost, m_cost, out_len = nil) out_len = (out_len || Constants::OUT_LEN).to_i - raise ArgonHashFail, "Invalid output length" if out_len < 1 + raise ::Argon2::Errors::InvalidOutputLength if out_len < 1 result = '' FFI::MemoryPointer.new(:char, out_len) do |buffer| ret = Ext.argon2id_hash_raw(t_cost, 1 << m_cost, 1, password, password.length, salt, salt.length, buffer, out_len) - raise ArgonHashFail, ERRORS[ret.abs] unless ret.zero? + raise ::Argon2::Errors::ExtError, ERRORS[ret.abs] unless ret.zero? result = buffer.read_string(out_len) end @@ -82,13 +87,13 @@ def self.hash_argon2id_encode(password, salt, t_cost, m_cost, secret) result = '' secretlen = secret.nil? ? 0 : secret.bytesize passwordlen = password.nil? ? 0 : password.bytesize - raise ArgonHashFail, "Invalid salt size" if salt.length != Constants::SALT_LEN + raise ::Argon2::Errors::InvalidSaltSize if salt.length != Constants::SALT_LEN FFI::MemoryPointer.new(:char, Constants::ENCODE_LEN) do |buffer| ret = Ext.argon2_wrap(buffer, password, passwordlen, salt, salt.length, t_cost, (1 << m_cost), 1, secret, secretlen) - raise ArgonHashFail, ERRORS[ret.abs] unless ret.zero? + raise ::Argon2::Errors::ExtError, ERRORS[ret.abs] unless ret.zero? result = buffer.read_string(Constants::ENCODE_LEN) end @@ -101,7 +106,7 @@ def self.argon2_verify(pwd, hash, secret) ret = Ext.wrap_argon2_verify(hash, pwd, passwordlen, secret, secretlen) return false if ERRORS[ret.abs] == 'ARGON2_DECODING_FAIL' - raise ArgonHashFail, ERRORS[ret.abs] unless ret.zero? + raise ::Argon2::Errors::ExtError, ERRORS[ret.abs] unless ret.zero? true end diff --git a/lib/argon2/password.rb b/lib/argon2/password.rb new file mode 100644 index 0000000..7af3ea2 --- /dev/null +++ b/lib/argon2/password.rb @@ -0,0 +1,220 @@ +# frozen_string_literal: true + +module Argon2 + ## + # Front-end API for the Argon2 module. + # + class Password + # Used as the default time cost if one isn't provided when calling + # Argon2::Password.create + DEFAULT_T_COST = 2 + # Used to validate the minimum acceptable time cost + MIN_T_COST = 1 + # Used to validate the maximum acceptable time cost + MAX_T_COST = 750 + # Used as the default memory cost if one isn't provided when calling + # Argon2::Password.create + DEFAULT_M_COST = 16 + # Used to validate the minimum acceptable memory cost + MIN_M_COST = 3 + # Used to validate the maximum acceptable memory cost + MAX_M_COST = 31 + # The complete Argon2 digest string (not to be confused with the checksum). + attr_reader :digest + # The hash portion of the stored password hash. + attr_reader :checksum + # The salt of the stored password hash. + attr_reader :salt + # Variant used (argon2i / argon2d / argon2id) + attr_reader :variant + # The version of the argon2 algorithm used to create the hash. + attr_reader :version + # The time cost factor used to create the hash. + attr_reader :t_cost + # The memory cost factor used to create the hash. + attr_reader :m_cost + # The parallelism cost factor used to create the hash. + attr_reader :p_cost + + ## + # Class methods + # + class << self + ## + # Takes a user provided password and returns an Argon2::Password instance + # with the resulting Argon2 hash. + # + # Usage: + # + # Argon2::Password.create(password) + # Argon2::Password.create(password, t_cost: 4, m_cost: 20) + # Argon2::Password.create(password, secret: pepper) + # Argon2::Password.create(password, m_cost: 17, secret: pepper) + # + # Currently available options: + # + # * :t_cost + # * :m_cost + # * :secret + # + def create(password, options = {}) + raise Argon2::Errors::InvalidPassword unless password.is_a?(String) + + t_cost = options[:t_cost] || DEFAULT_T_COST + m_cost = options[:m_cost] || DEFAULT_M_COST + + raise Argon2::Errors::InvalidTCost if t_cost < MIN_T_COST || t_cost > MAX_T_COST + raise Argon2::Errors::InvalidMCost if m_cost < MIN_M_COST || m_cost > MAX_M_COST + + # TODO: Add support for changing the p_cost + + salt = options[:salt_do_not_supply] || Engine.saltgen + secret = options[:secret] + + Argon2::Password.new( + Argon2::Engine.hash_argon2id_encode( + password, salt, t_cost, m_cost, secret + ) + ) + end + + ## + # Regex to validate if the provided String is a valid Argon2 hash output. + # + # Supports 1 and argon2id formats. + # + def valid_hash?(digest) + /^\$argon2(id?|d).{,113}/ =~ digest + end + + ## + # Takes a password, Argon2 hash, and optionally a secret, then uses the + # Argon2 C Library to verify if they match. + # + # Also accepts passing another Argon2::Password instance as the password, + # in which case it will compare the final Argon2 hash for each against + # each other. + # + # Usage: + # + # Argon2::Password.verify_password(password, argon2_hash) + # Argon2::Password.verify_password(password, argon2_hash, secret) + # + def verify_password(password, digest, secret = nil) + digest = digest.to_s + if password.is_a?(Argon2::Password) + password == Argon2::Password.new(digest) + else + Argon2::Engine.argon2_verify(password, digest, secret) + end + end + end + + ###################### + ## Instance Methods ## + ###################### + + ## + # Initialize an Argon2::Password instance using any valid Argon2 digest. + # + def initialize(digest) + digest = digest.to_s + + raise Argon2::Errors::InvalidHash unless valid_hash?(digest) + + # Split the digest into its component pieces + split_digest = split_hash(digest) + # Assign each piece to the Argon2::Password instance + @digest = digest + @variant = split_digest[:variant] + @version = split_digest[:version] + @t_cost = split_digest[:t_cost] + @m_cost = split_digest[:m_cost] + @p_cost = split_digest[:p_cost] + @salt = split_digest[:salt] + @checksum = split_digest[:checksum] + end + + ## + # Helper function to allow easily comparing an Argon2::Password against the + # provided password and secret. + # + def matches?(password, secret = nil) + self.class.verify_password(password, digest, secret) + end + + ## + # Compares two Argon2::Password instances to see if they come from the same + # digest/hash. + # + def ==(other) + # TODO: Should this return false instead of raising an error? + unless other.is_a?(Argon2::Password) + raise ArgumentError, + 'Can only compare an Argon2::Password against another Argon2::Password' + end + + digest == other.digest + end + + ## + # Converts an Argon2::Password instance into a String. + # + def to_s + digest.to_s + end + + ## + # Converts an Argon2::Password instance into a String. + # + def to_str + digest.to_str + end + + private + + ## + # Helper method to allow checking if a hash is valid in the initializer. + # + def valid_hash?(digest) + self.class.valid_hash?(digest) + end + + # FIXME: Reduce complexity/AbcSize + # rubocop:disable Metrics/AbcSize + + ## + # Helper method to extract the various values from a digest into attributes. + # + def split_hash(digest) + # TODO: Is there a better way to explode the digest into attributes? + _, variant, version, config, salt, checksum = digest.split('$') + # Regex magic to extract the values for each setting + version = /v=(\d+)/.match(version) + t_cost = /t=(\d+),/.match(config) + m_cost = /m=(\d+),/.match(config) + p_cost = /p=(\d+)/.match(config) + + # Make sure none of the values are missing + raise Argon2::Errors::InvalidVersion if version.nil? + raise Argon2::Errors::InvalidTCost if t_cost.nil? + raise Argon2::Errors::InvalidMCost if m_cost.nil? + raise Argon2::Errors::InvalidPCost if p_cost.nil? + + # Undo the 2^m_cost operation when encoding the hash to get the original + # m_cost input back. + m_cost = Math.log2(m_cost[1].to_i).to_i + + { + variant: variant.to_str, + version: version[1].to_i, + t_cost: t_cost[1].to_i, + m_cost: m_cost, + p_cost: p_cost[1].to_i, + salt: salt.to_str, + checksum: checksum.to_str + } + end + # rubocop:enable Metrics/AbcSize + end +end diff --git a/lib/argon2/version.rb b/lib/argon2/version.rb index 3ff3d13..f733eac 100644 --- a/lib/argon2/version.rb +++ b/lib/argon2/version.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true -# Standard Gem version constant. - module Argon2 - VERSION = "2.0.3" + ## + # Standard Gem version constant. + # + VERSION = "3.0.0" end diff --git a/sig/argon2.rbs b/sig/argon2.rbs deleted file mode 100644 index 8da18d9..0000000 --- a/sig/argon2.rbs +++ /dev/null @@ -1,15 +0,0 @@ -# Classes -module Argon2 - class Password - @t_cost: Integer - @m_cost: Integer - @salt: nil | String - @secret: nil | String - - def initialize: (?Hash[Symbol, Integer] options) -> (nil | String) - def create: (String pass) -> untyped - def self.create: (String pass) -> untyped - def self.valid_hash?: (string hash) -> Integer? - def self.verify_password: (untyped pass, untyped hash, ?nil secret) -> untyped - end -end diff --git a/sig/engine.rbs b/sig/engine.rbs new file mode 100644 index 0000000..ab99eab --- /dev/null +++ b/sig/engine.rbs @@ -0,0 +1,6 @@ +# Classes +module Argon2 + class Engine + def self.saltgen: () -> String + end +end diff --git a/sig/ffi_engine.rbs b/sig/ffi_engine.rbs new file mode 100644 index 0000000..4c6227b --- /dev/null +++ b/sig/ffi_engine.rbs @@ -0,0 +1,9 @@ +# Classes +module Argon2 + class Engine + def self.hash_argon2i: (String password, String salt, Integer t_cost, Integer m_cost, ?Integer? out_len) -> String + def self.hash_argon2id: (String password, String salt, Integer t_cost, Integer m_cost, ?Integer? out_len) -> String + def self.hash_argon2id_encode: (String password, String salt, Integer t_cost, Integer m_cost, String? secret) -> String + def self.argon2_verify: (String? pwd, String? hash, String? secret) -> bool + end +end diff --git a/sig/password.rbs b/sig/password.rbs new file mode 100644 index 0000000..4c5b20e --- /dev/null +++ b/sig/password.rbs @@ -0,0 +1,40 @@ +# Classes +module Argon2 + class Password + # Password constants + DEFAULT_T_COST: Integer + MIN_T_COST: Integer + MAX_T_COST: Integer + DEFAULT_M_COST: Integer + MIN_M_COST: Integer + MAX_M_COST: Integer + + # Password attributes + attr_reader digest: String? + attr_reader checksum: String? + attr_reader salt: String? + attr_reader variant: String? + attr_reader version: Integer? + attr_reader t_cost: Integer? + attr_reader m_cost: Integer? + attr_reader p_cost: Integer? + + # Password class methods + def self.create: (String password, ?Hash[Symbol, Integer & String] options) -> instance + def self.valid_hash?: (String digest) -> bool + def self.verify_password: (untyped password, untyped digest, ?String? secret) -> bool + + # Password instance methods + def initialize: (untyped digest) -> void + def matches?: (untyped password, ?String? secret) -> bool + def ==: (untyped password) -> bool + def to_s: () -> String + def to_str: () -> String + + private + + # Password instance methods (private) + def valid_hash?: (String digest) -> bool + def split_hash: (String digest) -> Hash[Symbol, String & Integer] + end +end diff --git a/test/api_test.rb b/test/api_test.rb index cd299a2..c5b655a 100644 --- a/test/api_test.rb +++ b/test/api_test.rb @@ -3,41 +3,78 @@ require 'test_helper' module Argon2 - # Simple stub to facilitate testing these variables - class Password - attr_accessor :t_cost, :m_cost, :secret - end + # Simple stub to facilitate testing these variables (stub now unnecessary) + # class Password + # attr_accessor :t_cost, :m_cost, :secret + # end end class Argon2APITest < Minitest::Test def test_create_default - assert pass = Argon2::Password.new + assert pass = Argon2::Password.create('mypassword') assert_instance_of Argon2::Password, pass assert_equal 16, pass.m_cost assert_equal 2, pass.t_cost - assert_nil pass.secret + # assert_nil pass.secret # Secret is not persisted on the Argon2::Password end def test_create_args - assert pass = Argon2::Password.new(t_cost: 4, m_cost: 12) + assert pass = Argon2::Password.create('mypassword', t_cost: 4, m_cost: 12) assert_instance_of Argon2::Password, pass assert_equal 12, pass.m_cost assert_equal 4, pass.t_cost - assert_nil pass.secret + # assert_nil pass.secret # Secret is not persisted on the Argon2::Password end + # For usage of the secret param, see key_test.rb def test_secret - assert pass = Argon2::Password.new(secret: "A secret") + assert pass = Argon2::Password.create('mypassword', secret: "A secret") + skip("The secret isn't kept on the Argon2::Password instance") assert_equal pass.secret, "A secret" end def test_hash - assert pass = Argon2::Password.new - assert pass.create('mypassword') + assert Argon2::Password.create('mypassword') end def test_valid_hash secure_pass = Argon2::Password.create('A secret') assert Argon2::Password.valid_hash?(secure_pass) end + + ############### + ## New Tests ## + ############### + ORIGINAL_PASSWORD = 'mypassword' + PEPPER = 'A secret' + + def test_create_password_without_parameters + assert argon2 = Argon2::Password.create(ORIGINAL_PASSWORD) + + assert argon2.is_a?(Argon2::Password) + assert_equal argon2.m_cost, 16 + assert_equal argon2.t_cost, 2 + assert argon2.matches?(ORIGINAL_PASSWORD) + assert Argon2::Password.verify_password(ORIGINAL_PASSWORD, argon2) + end + + def test_create_password_with_parameters + assert argon2 = Argon2::Password.create(ORIGINAL_PASSWORD, t_cost: 4, m_cost: 12) + + assert argon2.is_a?(Argon2::Password) + assert_equal argon2.m_cost, 12 + assert_equal argon2.t_cost, 4 + assert argon2.matches?(ORIGINAL_PASSWORD) + assert Argon2::Password.verify_password(ORIGINAL_PASSWORD, argon2) + end + + def test_create_password_with_secret + assert argon2 = Argon2::Password.create(ORIGINAL_PASSWORD, secret: PEPPER) + + assert argon2.is_a?(Argon2::Password) + assert_equal argon2.m_cost, 16 + assert_equal argon2.t_cost, 2 + assert argon2.matches?(ORIGINAL_PASSWORD, PEPPER) + assert Argon2::Password.verify_password(ORIGINAL_PASSWORD, argon2, PEPPER) + end end diff --git a/test/error_test.rb b/test/error_test.rb index be87dbb..d5355a1 100644 --- a/test/error_test.rb +++ b/test/error_test.rb @@ -4,28 +4,47 @@ class Argon2ErrorTest < Minitest::Test def test_ffi_fail - assert_raises Argon2::ArgonHashFail do + assert_raises Argon2::Error do Argon2::Engine.hash_argon2i("password", "somesalt\0\0\0\0\0\0\0\0", 2, 1) end end def test_memory_too_small - assert_raises Argon2::ArgonHashFail do + assert_raises Argon2::Error do Argon2::Engine.hash_argon2id_encode("password", "somesalt\0\0\0\0\0\0\0\0", 2, 1, nil) end end def test_salt_size - assert_raises Argon2::ArgonHashFail do + assert_raises Argon2::Error do Argon2::Engine.hash_argon2id_encode("password", "somesalt", 2, 16, nil) end end def test_passwd_null - assert_raises Argon2::ArgonHashFail do + assert_raises Argon2::Error do Argon2::Engine.hash_argon2id_encode(nil, "somesalt\0\0\0\0\0\0\0\0", 2, 16, nil) end end + + def test_error_inheritance + assert Argon2::Error < StandardError + assert Argon2::Errors::InvalidHash < Argon2::Error + assert Argon2::Errors::InvalidVersion < Argon2::Errors::InvalidHash + assert Argon2::Errors::InvalidCost < Argon2::Errors::InvalidHash + assert Argon2::Errors::InvalidTCost < Argon2::Errors::InvalidCost + assert Argon2::Errors::InvalidMCost < Argon2::Errors::InvalidCost + assert Argon2::Errors::InvalidPCost < Argon2::Errors::InvalidCost + assert Argon2::Errors::InvalidPassword < Argon2::Error + assert Argon2::Errors::InvalidSaltSize < Argon2::Error + assert Argon2::Errors::InvalidOutputLength < Argon2::Error + assert Argon2::Errors::ExtError < Argon2::Error + end + + def test_invalid_password_msg + assert_equal Argon2::Errors::InvalidPassword.new.message, + "Invalid password (expected a String)" + end end diff --git a/test/key_test.rb b/test/key_test.rb index 2360997..89dacab 100644 --- a/test/key_test.rb +++ b/test/key_test.rb @@ -7,15 +7,76 @@ class LowLevelArgon2Test < Minitest::Test PASS = "random password" def test_key_hash # Default hash - argon = Argon2::Password.new(t_cost: 2, m_cost: 16) - assert basehash = argon.create(PASS) + assert basehash = Argon2::Password.create(PASS, t_cost: 2, m_cost: 16) # Keyed hash - argon = Argon2::Password.new(t_cost: 2, m_cost: 16, secret: KEY) - assert keyhash = argon.create(PASS) + assert keyhash = Argon2::Password.create(PASS, t_cost: 2, m_cost: 16, secret: KEY) + + # Isn't this test somewhat pointless? Each password will have a new salt, + # so they can never match anyway. refute_equal basehash, keyhash + + # Demonstrate problem: + assert salthash = Argon2::Password.create(PASS, t_cost: 2, m_cost: 16) + refute_equal basehash, salthash + # Prove that it's not just the `==` being broken: + assert_equal basehash, basehash + assert_equal salthash, salthash + assert_equal keyhash, keyhash + # The keyed hash - without the key refute Argon2::Password.verify_password(PASS, keyhash) # With key assert Argon2::Password.verify_password(PASS, keyhash, KEY) end + + # So apparently the salt that's in the digest is actually different from the + # salt used to generate the argon2 hash. + # + # To demonstrate: + # + # salt = Argon2::Engine.saltgen + # argon2 = Argon2::Password.create('anysecret', salt_do_not_supply: salt) + # salt == argon2.salt + # => false + # salt.length + # => 16 + # argon2.salt.length + # => 22 + # salt + # => "\xCA\xFD\xED\x18\x10\xD1!R\xF2\xA9\f4\xD2\x966\x9D" + # argon2.salt + # => "yv3tGBDRIVLyqQw00pY2nQ" + # + # This combined with the fact that you're not supposed to supply the salt in + # the first place, makes me wonder if we should just hard deprecate passing in + # the salt at all. In its current state, it provides literally no value and + # allows developers to shoot themselves in the foot. + # + # This test might be blown away soon anyway, temp disable abc cop. + # rubocop:disable Metrics/AbcSize + def test_key_hash_with_same_salts + skip('The salt going in is not the same as the salt in the digest') + assert basehash = Argon2::Password.create(PASS) + assert salthash = Argon2::Password.create(PASS, salt_do_not_supply: basehash.salt) + assert keyhash = Argon2::Password.create(PASS, salt_do_not_supply: basehash.salt, secret: KEY) + + # Prove that you can create an identical copy + assert_equal basehash, salthash + # Prove that both hashes without peppers do not equal hash with pepper + refute_equal basehash, keyhash + refute_equal salthash, keyhash + + # Test unkeyed hashes + assert Argon2::Password.verify_password(PASS, basehash) + assert Argon2::Password.verify_password(PASS, salthash) + + refute Argon2::Password.verify_password(PASS, basehash, KEY) + refute Argon2::Password.verify_password(PASS, salthash, KEY) + + # Test keyed hash + refute Argon2::Password.verify_password(PASS, keyhash) + + assert Argon2::Password.verify_password(PASS, keyhash, KEY) + end + # rubocop:enable Metrics/AbcSize end diff --git a/test/low_level_test.rb b/test/low_level_test.rb index d62eead..3f42247 100644 --- a/test/low_level_test.rb +++ b/test/low_level_test.rb @@ -7,6 +7,7 @@ class LowLevelArgon2Test < Minitest::Test def test_that_it_has_a_version_number refute_nil ::Argon2::VERSION + assert ::Argon2::VERSION.is_a?(String) end def test_ffi_vector diff --git a/test/rubycheck_test.rb b/test/rubycheck_test.rb index 3b351c3..d6eb161 100644 --- a/test/rubycheck_test.rb +++ b/test/rubycheck_test.rb @@ -2,7 +2,8 @@ require 'test_helper' -TIMES = (ENV['TEST_CHECKS'] || 100).to_i +# The Github action sets this to 100, the value used to find the NULL hash bug. +TIMES = (ENV['TEST_CHECKS'] || 1).to_i # This was supposed to use Rubycheck, however the current version doesn't run # These property tests identified the NULL hash bug diff --git a/test/test_helper.rb b/test/test_helper.rb index c5490da..6b89aba 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -11,7 +11,10 @@ c.single_report_path = 'coverage/lcov.info' end SimpleCov.formatter = SimpleCov::Formatter::LcovFormatter -SimpleCov.start +SimpleCov.start do + # Don't test the coverage of the test suite itself... + add_filter '/test' +end require 'argon2' diff --git a/test/unicode_test.rb b/test/unicode_test.rb index d406280..5ebdb52 100644 --- a/test/unicode_test.rb +++ b/test/unicode_test.rb @@ -24,5 +24,7 @@ def test_emoji hash = Argon2::Password.create(rawstr) assert Argon2::Password.verify_password(rawstr, hash) refute Argon2::Password.verify_password("", hash) + # Also test if emoji are stripped but spaces remained. + refute Argon2::Password.verify_password(" ", hash) end end diff --git a/test/v3_tests/password_create_test.rb b/test/v3_tests/password_create_test.rb new file mode 100644 index 0000000..95eaddb --- /dev/null +++ b/test/v3_tests/password_create_test.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'test_helper' + +class PasswordCreateTest < Minitest::Test + # TODO: Randomly generate a new password with Faker + # SECRET = Faker::Internet.unique.password + SECRET = 'mysecretpassword' + # Time cost constants + DEFAULT_T_COST = Argon2::Password::DEFAULT_T_COST + MIN_T_COST = Argon2::Password::MIN_T_COST + MAX_T_COST = Argon2::Password::MAX_T_COST + # Memory cost constants + DEFAULT_M_COST = Argon2::Password::DEFAULT_M_COST + MIN_M_COST = Argon2::Password::MIN_M_COST + MAX_M_COST = Argon2::Password::MAX_M_COST + + def test_default_t_cost + assert pass = Argon2::Password.create(SECRET, t_cost: DEFAULT_T_COST) + + assert_instance_of Argon2::Password, pass + assert_equal DEFAULT_T_COST, pass.t_cost + end + + def test_min_t_cost + assert pass = Argon2::Password.create(SECRET, t_cost: MIN_T_COST) + + assert_instance_of Argon2::Password, pass + assert_equal MIN_T_COST, pass.t_cost + + # Ensure that going below the minimum results in an InvalidTCost error + assert_raises Argon2::Errors::InvalidTCost do + Argon2::Password.create(SECRET, t_cost: MIN_T_COST - 1) + end + end + + # FIXME: This is a really slow test due to _actually running_ the max cost. Is + # there a way to test that the max cost works without incurring this + # test suite speed penalty? + def test_max_t_cost + # assert pass = Argon2::Password.create(SECRET, t_cost: MAX_T_COST) + + # assert_instance_of Argon2::Password, pass + # assert_equal MAX_T_COST, pass.t_cost + + # Ensure that going above the maximum results in an InvalidTCost error + assert_raises Argon2::Errors::InvalidTCost do + Argon2::Password.create(SECRET, t_cost: MAX_T_COST + 1) + end + end + + def test_default_m_cost + assert pass = Argon2::Password.create(SECRET, m_cost: DEFAULT_M_COST) + + assert_instance_of Argon2::Password, pass + assert_equal DEFAULT_M_COST, pass.m_cost + end + + def test_min_m_cost + assert pass = Argon2::Password.create(SECRET, m_cost: MIN_M_COST) + + assert_instance_of Argon2::Password, pass + assert_equal MIN_M_COST, pass.m_cost + + # Ensure that going below the minimum results in an InvalidMCost error + assert_raises Argon2::Errors::InvalidMCost do + Argon2::Password.create(SECRET, m_cost: MIN_M_COST - 1) + end + end + + # FIXME: When testing maximum memory cost, fails due to: + # Argon2::Errors::ExtError: ARGON2_MEMORY_ALLOCATION_ERROR + def test_max_m_cost + # assert pass = Argon2::Password.create(SECRET, m_cost: MAX_M_COST) + + # assert_instance_of Argon2::Password, pass + # assert_equal MAX_M_COST, pass.m_cost + + # Ensure that going above the maximum results in an InvalidMCost error + assert_raises Argon2::Errors::InvalidMCost do + Argon2::Password.create(SECRET, m_cost: MAX_M_COST + 1) + end + end +end diff --git a/test/v3_tests/password_equals_test.rb b/test/v3_tests/password_equals_test.rb new file mode 100644 index 0000000..c4bbe98 --- /dev/null +++ b/test/v3_tests/password_equals_test.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'test_helper' + +class PasswordEqualsTest < Minitest::Test + # TODO: Randomly generate a new password with Faker + # SECRET = Faker::Internet.unique.password + SECRET = 'mysecretpassword' + PASS = Argon2::Password.create(SECRET) + DIGEST = PASS.to_s + + def test_refuses_string + assert_raises ArgumentError do + PASS == SECRET + end + + assert_raises ArgumentError do + PASS == DIGEST + end + end + + def test_accepts_argon2_password + assert_equal Argon2::Password.new(DIGEST), PASS + end +end diff --git a/test/v3_tests/password_matches_test.rb b/test/v3_tests/password_matches_test.rb new file mode 100644 index 0000000..61d7d9a --- /dev/null +++ b/test/v3_tests/password_matches_test.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'test_helper' + +class PasswordMatchesTest < Minitest::Test + # TODO: Randomly generate a new password with Faker + # SECRET = Faker::Internet.unique.password + SECRET = 'mysecretpassword' + PASS = Argon2::Password.create(SECRET) + DIGEST = PASS.to_s + # Confirm other values are invalid + OTHER_SECRET = 'notmysecretpassword' + OTHER_PASS = Argon2::Password.create(OTHER_SECRET) + OTHER_DIGEST = OTHER_PASS.to_s + + def test_accepts_string + assert PASS.matches?(SECRET) + assert OTHER_PASS.matches?(OTHER_SECRET) + + refute PASS.matches?(OTHER_SECRET) + refute OTHER_PASS.matches?(SECRET) + end + + def test_accepts_argon2_password + assert PASS.matches?(PASS) + assert OTHER_PASS.matches?(OTHER_PASS) + + refute PASS.matches?(OTHER_PASS) + refute OTHER_PASS.matches?(PASS) + end +end diff --git a/test/v3_tests/password_new_test.rb b/test/v3_tests/password_new_test.rb new file mode 100644 index 0000000..975b016 --- /dev/null +++ b/test/v3_tests/password_new_test.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'test_helper' + +class PasswordNewTest < Minitest::Test + # TODO: Randomly generate a new password with Faker + # SECRET = Faker::Internet.unique.password + SECRET = 'mysecretpassword' + PASS = Argon2::Password.create(SECRET) + DIGEST = PASS.to_s + # What would be appropriate bad digest(s) to test? Using a bcrypt hash for now + BAD_DIGEST = '$2a$05$CCCCCCCCCCCCCCCCCCCCC.E5YPO9kmyuRGyh0XouQYb4YMJKvyOeW' + + def test_accepts_valid_string + assert argon2 = Argon2::Password.new(DIGEST) + assert argon2.is_a?(Argon2::Password) + + assert_equal argon2, PASS + assert_equal argon2.to_s, DIGEST + + assert argon2.matches?(SECRET) + assert Argon2::Password.verify_password(SECRET, argon2) + assert Argon2::Password.verify_password(argon2, DIGEST) + end + + def test_rejects_invalid_string + assert_raises Argon2::Errors::InvalidHash do + Argon2::Password.new(BAD_DIGEST) + end + end + + def test_accepts_argon2_password + assert argon2 = Argon2::Password.new(PASS) + assert argon2.is_a?(Argon2::Password) + + assert_equal argon2, PASS + assert_equal argon2.to_s, DIGEST + + assert argon2.matches?(SECRET) + assert Argon2::Password.verify_password(SECRET, argon2) + assert Argon2::Password.verify_password(argon2, DIGEST) + end +end diff --git a/test/v3_tests/verify_password_test.rb b/test/v3_tests/verify_password_test.rb new file mode 100644 index 0000000..a35b4a8 --- /dev/null +++ b/test/v3_tests/verify_password_test.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'test_helper' + +class VerifyPasswordTest < Minitest::Test + # TODO: Randomly generate a new password with Faker + # SECRET = Faker::Internet.unique.password + SECRET = 'mysecretpassword' + PASS = Argon2::Password.create(SECRET) + DIGEST = PASS.to_s + # Confirm other values are invalid + OTHER_SECRET = 'notmysecretpassword' + OTHER_PASS = Argon2::Password.create(OTHER_SECRET) + OTHER_DIGEST = OTHER_PASS.to_s + + def test_accepts_string_as_secret + assert Argon2::Password.verify_password(SECRET, DIGEST) + assert Argon2::Password.verify_password(OTHER_SECRET, OTHER_DIGEST) + + refute Argon2::Password.verify_password(SECRET, OTHER_DIGEST) + refute Argon2::Password.verify_password(OTHER_SECRET, DIGEST) + end + + def test_accepts_argon2_password_as_secret + assert Argon2::Password.verify_password(PASS, DIGEST) + assert Argon2::Password.verify_password(OTHER_PASS, OTHER_DIGEST) + + refute Argon2::Password.verify_password(PASS, OTHER_DIGEST) + refute Argon2::Password.verify_password(OTHER_PASS, DIGEST) + end + + def test_accepts_argon2_password_as_digest + assert Argon2::Password.verify_password(SECRET, PASS) + assert Argon2::Password.verify_password(OTHER_SECRET, OTHER_PASS) + + refute Argon2::Password.verify_password(SECRET, OTHER_PASS) + refute Argon2::Password.verify_password(OTHER_SECRET, PASS) + end + + def test_accepts_tautology + assert Argon2::Password.verify_password(PASS, PASS) + assert Argon2::Password.verify_password(OTHER_PASS, OTHER_PASS) + + refute Argon2::Password.verify_password(PASS, OTHER_PASS) + refute Argon2::Password.verify_password(OTHER_PASS, PASS) + end +end