Skip to content

Commit 7a3eab1

Browse files
committed
Add Argon2id with password migration support
Add Argon2id as crypto provider and add password migration support - Configurable t_cost, m_cost, and p_cost parameters - Implements cost_matches? for automatic re-hashing on parameter changes - Works with transition_from_crypto_providers for lazy migration on login - argon2 ~> 2.0 added as development dependency
1 parent 9b8cfe8 commit 7a3eab1

7 files changed

Lines changed: 227 additions & 2 deletions

File tree

Gemfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@
22

33
source "https://rubygems.org"
44
gemspec
5+
6+
gem "sqlite3", "~> 2.9"

authlogic.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ require_relative "lib/authlogic/version"
2828
s.add_dependency "activerecord", [">= 7.2", "< 8.2"]
2929
s.add_dependency "activesupport", [">= 7.2", "< 8.2"]
3030
s.add_dependency "request_store", "~> 1.0"
31+
s.add_development_dependency "argon2", "~> 2.0"
3132
s.add_development_dependency "bcrypt", "~> 3.1"
3233
s.add_development_dependency "byebug", "~> 11.1.3"
3334
s.add_development_dependency "coveralls_reborn", "~> 0.29.0"

lib/authlogic/crypto_providers.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@ module CryptoProviders
2727
autoload :Sha1, "authlogic/crypto_providers/sha1"
2828
autoload :Sha256, "authlogic/crypto_providers/sha256"
2929
autoload :Sha512, "authlogic/crypto_providers/sha512"
30-
autoload :BCrypt, "authlogic/crypto_providers/bcrypt"
31-
autoload :SCrypt, "authlogic/crypto_providers/scrypt"
30+
autoload :BCrypt, "authlogic/crypto_providers/bcrypt"
31+
autoload :SCrypt, "authlogic/crypto_providers/scrypt"
32+
autoload :Argon2id, "authlogic/crypto_providers/argon2id"
3233

3334
# Guide users to choose a better crypto provider.
3435
class Guidance
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# frozen_string_literal: true
2+
3+
require "argon2"
4+
5+
module Authlogic
6+
module CryptoProviders
7+
# Argon2id is the recommended variant of the Argon2 password hashing
8+
# algorithm, which won the Password Hashing Competition in 2015. It
9+
# combines the side-channel resistance of Argon2i with the GPU/ASIC
10+
# attack resistance of Argon2d, making it the best choice for
11+
# password hashing.
12+
#
13+
# Argon2id has three configurable cost parameters:
14+
#
15+
# - t_cost: Number of iterations (time cost). Higher values increase
16+
# computation time. Default: 2
17+
# - m_cost: Memory usage in powers of 2 (in kibibytes).
18+
# For example, m_cost of 16 means 2^16 KiB = 64 MiB.
19+
# Default: 16 (64 MiB)
20+
# - p_cost: Degree of parallelism (number of threads). Default: 1
21+
#
22+
# To use Argon2id, install the argon2 gem:
23+
#
24+
# gem install argon2
25+
#
26+
# Tell acts_as_authentic to use it:
27+
#
28+
# acts_as_authentic do |c|
29+
# c.crypto_provider = Authlogic::CryptoProviders::Argon2id
30+
# end
31+
#
32+
# To transition from another provider (lazy migration on login):
33+
#
34+
# acts_as_authentic do |c|
35+
# c.crypto_provider = Authlogic::CryptoProviders::Argon2id
36+
# c.transition_from_crypto_providers = [Authlogic::CryptoProviders::SCrypt]
37+
# end
38+
#
39+
# To update cost parameters (existing passwords are re-hashed on
40+
# next login):
41+
#
42+
# Authlogic::CryptoProviders::Argon2id.t_cost = 3
43+
# Authlogic::CryptoProviders::Argon2id.m_cost = 17
44+
#
45+
class Argon2id
46+
class << self
47+
attr_writer :t_cost, :m_cost, :p_cost
48+
49+
# Time cost (number of iterations). Default: 2
50+
def t_cost
51+
@t_cost ||= 2
52+
end
53+
54+
# Memory cost as a power of 2 (in kibibytes). Default: 16 (64 MiB)
55+
def m_cost
56+
@m_cost ||= 16
57+
end
58+
59+
# Parallelism (number of threads). Default: 1
60+
def p_cost
61+
@p_cost ||= 1
62+
end
63+
64+
# Creates an Argon2id hash for the password passed.
65+
def encrypt(*tokens)
66+
hasher = ::Argon2::Password.new(
67+
t_cost: t_cost,
68+
m_cost: m_cost,
69+
p_cost: p_cost
70+
)
71+
hasher.create(join_tokens(tokens))
72+
end
73+
74+
# Does the hash match the tokens? Uses the same tokens that were
75+
# used to encrypt.
76+
def matches?(hash, *tokens)
77+
return false if hash.blank?
78+
::Argon2::Password.verify_password(join_tokens(tokens), hash)
79+
rescue ::Argon2::ArgonHashFail
80+
false
81+
end
82+
83+
# Checks whether the existing hash uses the same cost parameters
84+
# as the current configuration. If not, Authlogic will re-hash
85+
# the password on next successful login.
86+
def cost_matches?(hash)
87+
return false if hash.blank?
88+
params = extract_params(hash)
89+
return false if params.nil?
90+
params[:t] == t_cost &&
91+
params[:m] == (1 << m_cost) &&
92+
params[:p] == p_cost
93+
end
94+
95+
private
96+
97+
def join_tokens(tokens)
98+
tokens.flatten.join
99+
end
100+
101+
# Parses cost parameters from an Argon2id hash string.
102+
# Format: $argon2id$v=19$m=65536,t=2,p=1$salt$hash
103+
def extract_params(hash)
104+
match = hash.to_s.match(/\$argon2id?\$v=\d+\$m=(\d+),t=(\d+),p=(\d+)\$/)
105+
return nil unless match
106+
{ m: match[1].to_i, t: match[2].to_i, p: match[3].to_i }
107+
end
108+
end
109+
end
110+
end
111+
end

test/acts_as_authentic_test/password_test.rb

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,49 @@ def test_transitioning_password
9292
)
9393
end
9494

95+
def test_transitioning_password_to_argon2id
96+
ben = users(:ben)
97+
transition_password_to(Authlogic::CryptoProviders::Argon2id, ben)
98+
end
99+
100+
def test_transitioning_password_from_argon2id
101+
ben = users(:ben)
102+
transition_password_to(Authlogic::CryptoProviders::Argon2id, ben)
103+
transition_password_to(
104+
Authlogic::CryptoProviders::SCrypt,
105+
ben,
106+
Authlogic::CryptoProviders::Argon2id
107+
)
108+
end
109+
110+
def test_argon2id_cost_migration
111+
ben = users(:aaron)
112+
original_t_cost = Authlogic::CryptoProviders::Argon2id.t_cost
113+
114+
# Set up user with Argon2id
115+
User.acts_as_authentic do |c|
116+
c.crypto_provider = Authlogic::CryptoProviders::Argon2id
117+
c.transition_from_crypto_providers = []
118+
end
119+
ben.password = "aaronrocks"
120+
ben.password_confirmation = "aaronrocks"
121+
ben.save(validate: false)
122+
123+
old_hash = ben.crypted_password
124+
assert Authlogic::CryptoProviders::Argon2id.cost_matches?(old_hash)
125+
126+
# Increase cost
127+
Authlogic::CryptoProviders::Argon2id.t_cost = original_t_cost + 1
128+
refute Authlogic::CryptoProviders::Argon2id.cost_matches?(old_hash)
129+
130+
# On next valid_password?, it should re-hash
131+
assert ben.valid_password?("aaronrocks")
132+
assert_not_equal old_hash, ben.crypted_password
133+
assert Authlogic::CryptoProviders::Argon2id.cost_matches?(ben.crypted_password)
134+
ensure
135+
Authlogic::CryptoProviders::Argon2id.t_cost = original_t_cost
136+
end
137+
95138
def test_v2_crypto_provider_transition
96139
ben = users(:ben)
97140

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
5+
module CryptoProviderTest
6+
class Argon2idTest < ActiveSupport::TestCase
7+
def test_encrypt
8+
assert Authlogic::CryptoProviders::Argon2id.encrypt("mypass")
9+
end
10+
11+
def test_encrypt_produces_argon2id_hash
12+
hash = Authlogic::CryptoProviders::Argon2id.encrypt("mypass")
13+
assert hash.start_with?("$argon2id$")
14+
end
15+
16+
def test_matches
17+
hash = Authlogic::CryptoProviders::Argon2id.encrypt("mypass")
18+
assert Authlogic::CryptoProviders::Argon2id.matches?(hash, "mypass")
19+
end
20+
21+
def test_does_not_match_wrong_password
22+
hash = Authlogic::CryptoProviders::Argon2id.encrypt("mypass")
23+
refute Authlogic::CryptoProviders::Argon2id.matches?(hash, "wrongpass")
24+
end
25+
26+
def test_does_not_match_blank_hash
27+
refute Authlogic::CryptoProviders::Argon2id.matches?("", "mypass")
28+
refute Authlogic::CryptoProviders::Argon2id.matches?(nil, "mypass")
29+
end
30+
31+
def test_matches_with_multiple_tokens
32+
hash = Authlogic::CryptoProviders::Argon2id.encrypt("mypass", "salt123")
33+
assert Authlogic::CryptoProviders::Argon2id.matches?(hash, "mypass", "salt123")
34+
refute Authlogic::CryptoProviders::Argon2id.matches?(hash, "mypass", "othersalt")
35+
end
36+
37+
def test_cost_matches_with_current_params
38+
hash = Authlogic::CryptoProviders::Argon2id.encrypt("mypass")
39+
assert Authlogic::CryptoProviders::Argon2id.cost_matches?(hash)
40+
end
41+
42+
def test_cost_does_not_match_after_t_cost_change
43+
hash = Authlogic::CryptoProviders::Argon2id.encrypt("mypass")
44+
original_t_cost = Authlogic::CryptoProviders::Argon2id.t_cost
45+
begin
46+
Authlogic::CryptoProviders::Argon2id.t_cost = original_t_cost + 1
47+
refute Authlogic::CryptoProviders::Argon2id.cost_matches?(hash)
48+
ensure
49+
Authlogic::CryptoProviders::Argon2id.t_cost = original_t_cost
50+
end
51+
end
52+
53+
def test_cost_does_not_match_blank_hash
54+
refute Authlogic::CryptoProviders::Argon2id.cost_matches?("")
55+
refute Authlogic::CryptoProviders::Argon2id.cost_matches?(nil)
56+
end
57+
58+
def test_does_not_match_invalid_hash
59+
refute Authlogic::CryptoProviders::Argon2id.matches?("not_a_real_hash", "mypass")
60+
end
61+
end
62+
end

test/test_helper.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,11 @@
163163
Authlogic::CryptoProviders::SCrypt.max_time = 0.001 # 1ms
164164
Authlogic::CryptoProviders::SCrypt.max_mem = 1024 * 1024 # 1MB, the minimum SCrypt allows
165165

166+
# Configure Argon2id to be as fast as possible for tests.
167+
Authlogic::CryptoProviders::Argon2id.t_cost = 1
168+
Authlogic::CryptoProviders::Argon2id.m_cost = 3 # 2^3 = 8 KiB, minimum Argon2 allows
169+
Authlogic::CryptoProviders::Argon2id.p_cost = 1
170+
166171
require "libs/project"
167172
require "libs/affiliate"
168173
require "libs/employee"

0 commit comments

Comments
 (0)