diff --git a/Gemfile b/Gemfile index bb94df82..0311aedd 100644 --- a/Gemfile +++ b/Gemfile @@ -2,3 +2,5 @@ source "https://rubygems.org" gemspec + +gem "sqlite3", "~> 2.9" diff --git a/authlogic.gemspec b/authlogic.gemspec index 7eaf0b29..ccec8921 100644 --- a/authlogic.gemspec +++ b/authlogic.gemspec @@ -28,6 +28,7 @@ require_relative "lib/authlogic/version" s.add_dependency "activerecord", [">= 7.2", "< 8.2"] s.add_dependency "activesupport", [">= 7.2", "< 8.2"] s.add_dependency "request_store", "~> 1.0" + s.add_development_dependency "argon2", "~> 2.0" s.add_development_dependency "bcrypt", "~> 3.1" s.add_development_dependency "byebug", "~> 11.1.3" s.add_development_dependency "coveralls_reborn", "~> 0.29.0" diff --git a/lib/authlogic/crypto_providers.rb b/lib/authlogic/crypto_providers.rb index 3e4b4711..753c3594 100644 --- a/lib/authlogic/crypto_providers.rb +++ b/lib/authlogic/crypto_providers.rb @@ -27,8 +27,9 @@ module CryptoProviders autoload :Sha1, "authlogic/crypto_providers/sha1" autoload :Sha256, "authlogic/crypto_providers/sha256" autoload :Sha512, "authlogic/crypto_providers/sha512" - autoload :BCrypt, "authlogic/crypto_providers/bcrypt" - autoload :SCrypt, "authlogic/crypto_providers/scrypt" + autoload :BCrypt, "authlogic/crypto_providers/bcrypt" + autoload :SCrypt, "authlogic/crypto_providers/scrypt" + autoload :Argon2id, "authlogic/crypto_providers/argon2id" # Guide users to choose a better crypto provider. class Guidance diff --git a/lib/authlogic/crypto_providers/argon2id.rb b/lib/authlogic/crypto_providers/argon2id.rb new file mode 100644 index 00000000..062025d6 --- /dev/null +++ b/lib/authlogic/crypto_providers/argon2id.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "argon2" + +module Authlogic + module CryptoProviders + # Argon2id is the recommended variant of the Argon2 password hashing + # algorithm, which won the Password Hashing Competition in 2015. It + # combines the side-channel resistance of Argon2i with the GPU/ASIC + # attack resistance of Argon2d, making it the best choice for + # password hashing. + # + # Argon2id has three configurable cost parameters: + # + # - t_cost: Number of iterations (time cost). Higher values increase + # computation time. Default: 2 + # - m_cost: Memory usage in powers of 2 (in kibibytes). + # For example, m_cost of 16 means 2^16 KiB = 64 MiB. + # Default: 16 (64 MiB) + # - p_cost: Degree of parallelism (number of threads). Default: 1 + # + # To use Argon2id, install the argon2 gem: + # + # gem install argon2 + # + # Tell acts_as_authentic to use it: + # + # acts_as_authentic do |c| + # c.crypto_provider = Authlogic::CryptoProviders::Argon2id + # end + # + # To transition from another provider (lazy migration on login): + # + # acts_as_authentic do |c| + # c.crypto_provider = Authlogic::CryptoProviders::Argon2id + # c.transition_from_crypto_providers = [Authlogic::CryptoProviders::SCrypt] + # end + # + # To update cost parameters (existing passwords are re-hashed on + # next login): + # + # Authlogic::CryptoProviders::Argon2id.t_cost = 3 + # Authlogic::CryptoProviders::Argon2id.m_cost = 17 + # + class Argon2id + class << self + attr_writer :t_cost, :m_cost, :p_cost + + # Time cost (number of iterations). Default: 2 + def t_cost + @t_cost ||= 2 + end + + # Memory cost as a power of 2 (in kibibytes). Default: 16 (64 MiB) + def m_cost + @m_cost ||= 16 + end + + # Parallelism (number of threads). Default: 1 + def p_cost + @p_cost ||= 1 + end + + # Creates an Argon2id hash for the password passed. + def encrypt(*tokens) + hasher = ::Argon2::Password.new( + t_cost: t_cost, + m_cost: m_cost, + p_cost: p_cost + ) + hasher.create(join_tokens(tokens)) + end + + # Does the hash match the tokens? Uses the same tokens that were + # used to encrypt. + def matches?(hash, *tokens) + return false if hash.blank? + ::Argon2::Password.verify_password(join_tokens(tokens), hash) + rescue ::Argon2::ArgonHashFail + false + end + + # Checks whether the existing hash uses the same cost parameters + # as the current configuration. If not, Authlogic will re-hash + # the password on next successful login. + def cost_matches?(hash) + return false if hash.blank? + params = extract_params(hash) + return false if params.nil? + params[:t] == t_cost && + params[:m] == (1 << m_cost) && + params[:p] == p_cost + end + + private + + def join_tokens(tokens) + tokens.flatten.join + end + + # Parses cost parameters from an Argon2id hash string. + # Format: $argon2id$v=19$m=65536,t=2,p=1$salt$hash + def extract_params(hash) + match = hash.to_s.match(/\$argon2id?\$v=\d+\$m=(\d+),t=(\d+),p=(\d+)\$/) + return nil unless match + { m: match[1].to_i, t: match[2].to_i, p: match[3].to_i } + end + end + end + end +end diff --git a/test/acts_as_authentic_test/password_test.rb b/test/acts_as_authentic_test/password_test.rb index e13c76dd..bb905d0e 100644 --- a/test/acts_as_authentic_test/password_test.rb +++ b/test/acts_as_authentic_test/password_test.rb @@ -92,6 +92,49 @@ def test_transitioning_password ) end + def test_transitioning_password_to_argon2id + ben = users(:ben) + transition_password_to(Authlogic::CryptoProviders::Argon2id, ben) + end + + def test_transitioning_password_from_argon2id + ben = users(:ben) + transition_password_to(Authlogic::CryptoProviders::Argon2id, ben) + transition_password_to( + Authlogic::CryptoProviders::SCrypt, + ben, + Authlogic::CryptoProviders::Argon2id + ) + end + + def test_argon2id_cost_migration + ben = users(:aaron) + original_t_cost = Authlogic::CryptoProviders::Argon2id.t_cost + + # Set up user with Argon2id + User.acts_as_authentic do |c| + c.crypto_provider = Authlogic::CryptoProviders::Argon2id + c.transition_from_crypto_providers = [] + end + ben.password = "aaronrocks" + ben.password_confirmation = "aaronrocks" + ben.save(validate: false) + + old_hash = ben.crypted_password + assert Authlogic::CryptoProviders::Argon2id.cost_matches?(old_hash) + + # Increase cost + Authlogic::CryptoProviders::Argon2id.t_cost = original_t_cost + 1 + refute Authlogic::CryptoProviders::Argon2id.cost_matches?(old_hash) + + # On next valid_password?, it should re-hash + assert ben.valid_password?("aaronrocks") + assert_not_equal old_hash, ben.crypted_password + assert Authlogic::CryptoProviders::Argon2id.cost_matches?(ben.crypted_password) + ensure + Authlogic::CryptoProviders::Argon2id.t_cost = original_t_cost + end + def test_v2_crypto_provider_transition ben = users(:ben) diff --git a/test/crypto_provider_test/argon2id_test.rb b/test/crypto_provider_test/argon2id_test.rb new file mode 100644 index 00000000..f2d34900 --- /dev/null +++ b/test/crypto_provider_test/argon2id_test.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "test_helper" + +module CryptoProviderTest + class Argon2idTest < ActiveSupport::TestCase + def test_encrypt + assert Authlogic::CryptoProviders::Argon2id.encrypt("mypass") + end + + def test_encrypt_produces_argon2id_hash + hash = Authlogic::CryptoProviders::Argon2id.encrypt("mypass") + assert hash.start_with?("$argon2id$") + end + + def test_matches + hash = Authlogic::CryptoProviders::Argon2id.encrypt("mypass") + assert Authlogic::CryptoProviders::Argon2id.matches?(hash, "mypass") + end + + def test_does_not_match_wrong_password + hash = Authlogic::CryptoProviders::Argon2id.encrypt("mypass") + refute Authlogic::CryptoProviders::Argon2id.matches?(hash, "wrongpass") + end + + def test_does_not_match_blank_hash + refute Authlogic::CryptoProviders::Argon2id.matches?("", "mypass") + refute Authlogic::CryptoProviders::Argon2id.matches?(nil, "mypass") + end + + def test_matches_with_multiple_tokens + hash = Authlogic::CryptoProviders::Argon2id.encrypt("mypass", "salt123") + assert Authlogic::CryptoProviders::Argon2id.matches?(hash, "mypass", "salt123") + refute Authlogic::CryptoProviders::Argon2id.matches?(hash, "mypass", "othersalt") + end + + def test_cost_matches_with_current_params + hash = Authlogic::CryptoProviders::Argon2id.encrypt("mypass") + assert Authlogic::CryptoProviders::Argon2id.cost_matches?(hash) + end + + def test_cost_does_not_match_after_t_cost_change + hash = Authlogic::CryptoProviders::Argon2id.encrypt("mypass") + original_t_cost = Authlogic::CryptoProviders::Argon2id.t_cost + begin + Authlogic::CryptoProviders::Argon2id.t_cost = original_t_cost + 1 + refute Authlogic::CryptoProviders::Argon2id.cost_matches?(hash) + ensure + Authlogic::CryptoProviders::Argon2id.t_cost = original_t_cost + end + end + + def test_cost_does_not_match_blank_hash + refute Authlogic::CryptoProviders::Argon2id.cost_matches?("") + refute Authlogic::CryptoProviders::Argon2id.cost_matches?(nil) + end + + def test_does_not_match_invalid_hash + refute Authlogic::CryptoProviders::Argon2id.matches?("not_a_real_hash", "mypass") + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index f2e3de8c..01e0303b 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -163,6 +163,11 @@ Authlogic::CryptoProviders::SCrypt.max_time = 0.001 # 1ms Authlogic::CryptoProviders::SCrypt.max_mem = 1024 * 1024 # 1MB, the minimum SCrypt allows +# Configure Argon2id to be as fast as possible for tests. +Authlogic::CryptoProviders::Argon2id.t_cost = 1 +Authlogic::CryptoProviders::Argon2id.m_cost = 3 # 2^3 = 8 KiB, minimum Argon2 allows +Authlogic::CryptoProviders::Argon2id.p_cost = 1 + require "libs/project" require "libs/affiliate" require "libs/employee"