Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@

source "https://rubygems.org"
gemspec

gem "sqlite3", "~> 2.9"
1 change: 1 addition & 0 deletions authlogic.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 3 additions & 2 deletions lib/authlogic/crypto_providers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
111 changes: 111 additions & 0 deletions lib/authlogic/crypto_providers/argon2id.rb
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions test/acts_as_authentic_test/password_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
62 changes: 62 additions & 0 deletions test/crypto_provider_test/argon2id_test.rb
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down