scrypt 3.0.7 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/README.md +149 -23
- data/Rakefile +36 -23
- data/ext/scrypt/Rakefile +1 -1
- data/ext/scrypt/warnp.c +7 -1
- data/lib/scrypt/engine.rb +95 -43
- data/lib/scrypt/password.rb +48 -18
- data/lib/scrypt/scrypt_ext.rb +5 -1
- data/lib/scrypt/version.rb +1 -1
- data/scrypt.gemspec +7 -20
- data/spec/fixtures/test_vectors.yml +67 -0
- data/spec/scrypt/engine_spec.rb +159 -30
- data/spec/scrypt/ffi_spec.rb +33 -0
- data/spec/scrypt/integration_spec.rb +129 -0
- data/spec/scrypt/password_spec.rb +104 -56
- data/spec/scrypt/utils_spec.rb +48 -7
- data/spec/spec_helper.rb +40 -0
- data/spec/support/shared_examples.rb +46 -0
- data/spec/support/test_helpers.rb +47 -0
- data.tar.gz.sig +0 -0
- metadata +39 -168
- metadata.gz.sig +0 -0
- data/autotest/discover.rb +0 -1
@@ -3,135 +3,183 @@
|
|
3
3
|
require File.expand_path(File.join(File.dirname(__FILE__), '..', 'spec_helper'))
|
4
4
|
|
5
5
|
describe 'Creating a hashed password' do
|
6
|
-
before
|
6
|
+
before do
|
7
7
|
@password = SCrypt::Password.create('s3cr3t', max_time: 0.25)
|
8
8
|
end
|
9
9
|
|
10
|
-
it '
|
10
|
+
it 'returns a SCrypt::Password' do
|
11
11
|
expect(@password).to be_an_instance_of(SCrypt::Password)
|
12
12
|
end
|
13
13
|
|
14
|
-
it '
|
15
|
-
expect
|
14
|
+
it 'returns a valid password' do
|
15
|
+
expect { SCrypt::Password.new(@password) }.not_to raise_error
|
16
16
|
end
|
17
17
|
|
18
|
-
it '
|
19
|
-
expect
|
20
|
-
expect
|
21
|
-
expect
|
18
|
+
it 'behaves normally if the secret is not a string' do
|
19
|
+
expect { SCrypt::Password.create(nil) }.not_to raise_error
|
20
|
+
expect { SCrypt::Password.create(false) }.not_to raise_error
|
21
|
+
expect { SCrypt::Password.create(42) }.not_to raise_error
|
22
22
|
end
|
23
23
|
|
24
|
-
it '
|
25
|
-
expect
|
26
|
-
expect
|
27
|
-
expect(
|
24
|
+
it 'tolerates empty string secrets' do
|
25
|
+
expect { SCrypt::Password.create('') }.not_to raise_error
|
26
|
+
expect { SCrypt::Password.create('', max_time: 0.01) }.not_to raise_error
|
27
|
+
expect(SCrypt::Password.create('')).to be_an_instance_of(SCrypt::Password)
|
28
28
|
end
|
29
29
|
end
|
30
30
|
|
31
31
|
describe 'Reading a hashed password' do
|
32
|
-
before
|
32
|
+
before do
|
33
33
|
@secret = 'my secret'
|
34
34
|
@hash = '400$8$d$173a8189751c095a29b933789560b73bf17b2e01$9bf66d74bd6f3ebcf99da3b379b689b89db1cb07'
|
35
35
|
end
|
36
36
|
|
37
|
-
it '
|
37
|
+
it 'reads the cost, salt, and hash' do
|
38
38
|
password = SCrypt::Password.new(@hash)
|
39
39
|
expect(password.cost).to eq('400$8$d$')
|
40
40
|
expect(password.salt).to eq('173a8189751c095a29b933789560b73bf17b2e01')
|
41
|
-
expect(password.
|
41
|
+
expect(password.digest).to eq('9bf66d74bd6f3ebcf99da3b379b689b89db1cb07')
|
42
42
|
end
|
43
43
|
|
44
|
-
it '
|
45
|
-
expect
|
44
|
+
it 'raises an InvalidHashError when given an invalid hash' do
|
45
|
+
expect { SCrypt::Password.new('invalid') }.to raise_error(SCrypt::Errors::InvalidHash)
|
46
46
|
end
|
47
47
|
end
|
48
48
|
|
49
49
|
describe 'Comparing a hashed password with a secret' do
|
50
|
-
before
|
50
|
+
before do
|
51
51
|
@secret = 's3cr3t'
|
52
|
-
@password = SCrypt::Password.create(@secret)
|
52
|
+
@password = SCrypt::Password.create(@secret, max_time: 0.01)
|
53
53
|
end
|
54
54
|
|
55
|
-
it '
|
56
|
-
expect(
|
55
|
+
it 'compares successfully to the original secret' do
|
56
|
+
expect(@password == @secret).to be true
|
57
57
|
end
|
58
58
|
|
59
|
-
it '
|
60
|
-
expect(
|
59
|
+
it 'compares unsuccessfully to anything besides original secret' do
|
60
|
+
expect(@password == 'different').to be false
|
61
61
|
end
|
62
62
|
end
|
63
63
|
|
64
64
|
describe 'non-default salt sizes' do
|
65
|
-
before
|
65
|
+
before do
|
66
66
|
@secret = 's3cret'
|
67
67
|
end
|
68
68
|
|
69
|
-
it '
|
70
|
-
@password = SCrypt::Password.create(@secret, salt_size:
|
71
|
-
expect(@password.salt.length).to eq(8 * 2)
|
69
|
+
it 'enforces a minimum salt of 8 bytes' do
|
70
|
+
@password = SCrypt::Password.create(@secret, salt_size: 4, max_time: 0.01)
|
71
|
+
expect(@password.salt.length).to eq(16) # 8 bytes * 2 (hex encoding)
|
72
72
|
end
|
73
73
|
|
74
|
-
it '
|
75
|
-
@password = SCrypt::Password.create(@secret, salt_size: 32)
|
76
|
-
expect(@password.salt.length).to eq(32 * 2)
|
74
|
+
it 'allows a salt of 32 bytes' do
|
75
|
+
@password = SCrypt::Password.create(@secret, salt_size: 32, max_time: 0.01)
|
76
|
+
expect(@password.salt.length).to eq(64) # 32 bytes * 2 (hex encoding)
|
77
77
|
end
|
78
78
|
|
79
|
-
it '
|
80
|
-
@password = SCrypt::Password.create(@secret, salt_size:
|
81
|
-
expect(@password.salt.length).to eq(32 * 2)
|
79
|
+
it 'enforces a maximum salt of 32 bytes' do
|
80
|
+
@password = SCrypt::Password.create(@secret, salt_size: 64, max_time: 0.01)
|
81
|
+
expect(@password.salt.length).to eq(64) # 32 bytes * 2 (hex encoding)
|
82
82
|
end
|
83
83
|
|
84
|
-
it '
|
84
|
+
it 'pads a 20-byte salt to not look like a 20-byte SHA1' do
|
85
85
|
@password = SCrypt::Password.create(@secret, salt_size: 20)
|
86
86
|
expect(@password.salt.length).to eq(41)
|
87
87
|
end
|
88
88
|
|
89
|
-
it '
|
90
|
-
@password = SCrypt::Password.create(@secret, salt_size:
|
91
|
-
expect(
|
89
|
+
it 'properly compares a non-standard salt hash' do
|
90
|
+
@password = SCrypt::Password.create(@secret, salt_size: 16, max_time: 0.01)
|
91
|
+
expect(@password == @secret).to be true
|
92
92
|
end
|
93
93
|
end
|
94
94
|
|
95
95
|
describe 'non-default key lengths' do
|
96
|
-
before
|
96
|
+
before do
|
97
97
|
@secret = 's3cret'
|
98
98
|
end
|
99
99
|
|
100
|
-
it '
|
101
|
-
@password = SCrypt::Password.create(@secret, key_len:
|
102
|
-
expect(@password.digest.length).to eq(16 * 2)
|
100
|
+
it 'enforces a minimum keylength of 16 bytes' do
|
101
|
+
@password = SCrypt::Password.create(@secret, key_len: 8, max_time: 0.01)
|
102
|
+
expect(@password.digest.length).to eq(32) # 16 bytes * 2 (hex encoding)
|
103
103
|
end
|
104
104
|
|
105
|
-
it '
|
106
|
-
@password = SCrypt::Password.create(@secret, key_len: 512)
|
107
|
-
expect(@password.digest.length).to eq(512 * 2)
|
105
|
+
it 'allows a keylength of 512 bytes' do
|
106
|
+
@password = SCrypt::Password.create(@secret, key_len: 512, max_time: 0.01)
|
107
|
+
expect(@password.digest.length).to eq(1024) # 512 bytes * 2 (hex encoding)
|
108
108
|
end
|
109
109
|
|
110
|
-
it '
|
111
|
-
@password = SCrypt::Password.create(@secret, key_len:
|
112
|
-
expect(@password.digest.length).to eq(512 * 2)
|
110
|
+
it 'enforces a maximum keylength of 512 bytes' do
|
111
|
+
@password = SCrypt::Password.create(@secret, key_len: 1024, max_time: 0.01)
|
112
|
+
expect(@password.digest.length).to eq(1024) # 512 bytes * 2 (hex encoding)
|
113
113
|
end
|
114
114
|
|
115
|
-
it '
|
116
|
-
@password = SCrypt::Password.create(@secret, key_len:
|
117
|
-
expect(
|
115
|
+
it 'properly compares a non-standard hash' do
|
116
|
+
@password = SCrypt::Password.create(@secret, key_len: 64, max_time: 0.01)
|
117
|
+
expect(@password == @secret).to be true
|
118
118
|
end
|
119
119
|
end
|
120
120
|
|
121
121
|
describe 'Old-style hashes' do
|
122
|
-
before
|
122
|
+
before do
|
123
123
|
@secret = 'my secret'
|
124
124
|
@hash = '400$8$d$173a8189751c095a29b933789560b73bf17b2e01$9bf66d74bd6f3ebcf99da3b379b689b89db1cb07'
|
125
125
|
end
|
126
126
|
|
127
|
-
it '
|
128
|
-
expect(
|
127
|
+
it 'compares successfully' do
|
128
|
+
expect(SCrypt::Password.new(@hash) == @secret).to be true
|
129
129
|
end
|
130
130
|
end
|
131
131
|
|
132
132
|
describe 'Respecting standard ruby behaviors' do
|
133
|
-
it '
|
134
|
-
password = SCrypt::Password.create('')
|
135
|
-
expect(password.hash).to
|
133
|
+
it 'hashes as an integer' do
|
134
|
+
password = SCrypt::Password.create('secret', max_time: 0.01)
|
135
|
+
expect(password.hash).to be_an(Integer)
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
describe 'Password validation and parsing' do
|
140
|
+
it 'correctly parses hash components' do
|
141
|
+
password = SCrypt::Password.new('400$8$d$173a8189751c095a29b933789560b73bf17b2e01$9bf66d74bd6f3ebcf99da3b379b689b89db1cb07')
|
142
|
+
|
143
|
+
expect(password.cost).to eq('400$8$d$')
|
144
|
+
expect(password.salt).to eq('173a8189751c095a29b933789560b73bf17b2e01')
|
145
|
+
expect(password.digest).to eq('9bf66d74bd6f3ebcf99da3b379b689b89db1cb07')
|
146
|
+
end
|
147
|
+
|
148
|
+
it 'validates hash format strictly' do
|
149
|
+
valid_hash = '400$8$d$173a8189751c095a29b933789560b73bf17b2e01$9bf66d74bd6f3ebcf99da3b379b689b89db1cb07'
|
150
|
+
|
151
|
+
expect { SCrypt::Password.new(valid_hash) }.not_to raise_error
|
152
|
+
expect { SCrypt::Password.new('invalid') }.to raise_error(SCrypt::Errors::InvalidHash)
|
153
|
+
expect { SCrypt::Password.new('') }.to raise_error(SCrypt::Errors::InvalidHash)
|
154
|
+
expect { SCrypt::Password.new('400$8$d$') }.to raise_error(SCrypt::Errors::InvalidHash)
|
155
|
+
end
|
156
|
+
|
157
|
+
it 'handles alias method correctly' do
|
158
|
+
password = SCrypt::Password.create('secret', max_time: 0.01)
|
159
|
+
|
160
|
+
expect(password.is_password?('secret')).to be true
|
161
|
+
expect(password.is_password?('wrong')).to be false
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
describe 'Parameter boundary testing' do
|
166
|
+
it 'enforces minimum and maximum key lengths correctly' do
|
167
|
+
# Test minimum key length (should be clamped to 16)
|
168
|
+
password_min = SCrypt::Password.create('secret', key_len: 8, max_time: 0.01)
|
169
|
+
expect(password_min.digest.length).to eq(32) # 16 bytes * 2 (hex encoding)
|
170
|
+
|
171
|
+
# Test maximum key length (should be clamped to 512)
|
172
|
+
password_max = SCrypt::Password.create('secret', key_len: 1024, max_time: 0.01)
|
173
|
+
expect(password_max.digest.length).to eq(1024) # 512 bytes * 2 (hex encoding)
|
174
|
+
end
|
175
|
+
|
176
|
+
it 'enforces minimum and maximum salt sizes correctly' do
|
177
|
+
# Test minimum salt size (should be clamped to 8)
|
178
|
+
password_min = SCrypt::Password.create('secret', salt_size: 4, max_time: 0.01)
|
179
|
+
expect(password_min.salt.length).to eq(16) # 8 bytes * 2 (hex encoding)
|
180
|
+
|
181
|
+
# Test maximum salt size (should be clamped to 32)
|
182
|
+
password_max = SCrypt::Password.create('secret', salt_size: 64, max_time: 0.01)
|
183
|
+
expect(password_max.salt.length).to eq(64) # 32 bytes * 2 (hex encoding)
|
136
184
|
end
|
137
185
|
end
|
data/spec/scrypt/utils_spec.rb
CHANGED
@@ -3,12 +3,53 @@
|
|
3
3
|
require File.expand_path(File.join(File.dirname(__FILE__), '..', 'spec_helper'))
|
4
4
|
|
5
5
|
describe 'Security Utils' do
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
6
|
+
describe '.secure_compare' do
|
7
|
+
it 'performs a string comparison correctly' do
|
8
|
+
expect(SCrypt::SecurityUtils.secure_compare('a', 'a')).to equal(true)
|
9
|
+
expect(SCrypt::SecurityUtils.secure_compare('a', 'b')).to equal(false)
|
10
|
+
expect(SCrypt::SecurityUtils.secure_compare('aa', 'aa')).to equal(true)
|
11
|
+
expect(SCrypt::SecurityUtils.secure_compare('aa', 'ab')).to equal(false)
|
12
|
+
end
|
13
|
+
|
14
|
+
it 'returns false for different length strings' do
|
15
|
+
expect(SCrypt::SecurityUtils.secure_compare('aa', 'aaa')).to equal(false)
|
16
|
+
expect(SCrypt::SecurityUtils.secure_compare('aaa', 'aa')).to equal(false)
|
17
|
+
expect(SCrypt::SecurityUtils.secure_compare('', 'a')).to equal(false)
|
18
|
+
expect(SCrypt::SecurityUtils.secure_compare('a', '')).to equal(false)
|
19
|
+
end
|
20
|
+
|
21
|
+
it 'handles empty strings correctly' do
|
22
|
+
expect(SCrypt::SecurityUtils.secure_compare('', '')).to equal(true)
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'handles binary data correctly' do
|
26
|
+
binary1 = "\x00\x01\x02\x03"
|
27
|
+
binary2 = "\x00\x01\x02\x03"
|
28
|
+
binary3 = "\x00\x01\x02\x04"
|
29
|
+
|
30
|
+
expect(SCrypt::SecurityUtils.secure_compare(binary1, binary2)).to equal(true)
|
31
|
+
expect(SCrypt::SecurityUtils.secure_compare(binary1, binary3)).to equal(false)
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'handles unicode strings correctly' do
|
35
|
+
unicode1 = 'héllo'
|
36
|
+
unicode2 = 'héllo'
|
37
|
+
unicode3 = 'hello'
|
38
|
+
|
39
|
+
expect(SCrypt::SecurityUtils.secure_compare(unicode1, unicode2)).to equal(true)
|
40
|
+
expect(SCrypt::SecurityUtils.secure_compare(unicode1, unicode3)).to equal(false)
|
41
|
+
end
|
42
|
+
|
43
|
+
it 'is resistant to timing attacks' do
|
44
|
+
# This test ensures the function takes constant time regardless of where differences occur
|
45
|
+
long_string1 = ('a' * 1000) + 'x'
|
46
|
+
long_string2 = ('a' * 1000) + 'y'
|
47
|
+
long_string3 = 'x' + ('a' * 1000)
|
48
|
+
long_string4 = 'y' + ('a' * 1000)
|
49
|
+
|
50
|
+
# All of these should return false and take similar time
|
51
|
+
expect(SCrypt::SecurityUtils.secure_compare(long_string1, long_string2)).to equal(false)
|
52
|
+
expect(SCrypt::SecurityUtils.secure_compare(long_string3, long_string4)).to equal(false)
|
53
|
+
end
|
13
54
|
end
|
14
55
|
end
|
data/spec/spec_helper.rb
CHANGED
@@ -4,4 +4,44 @@ $LOAD_PATH.unshift(File.expand_path(File.dirname(__FILE__) + '/../lib'))
|
|
4
4
|
|
5
5
|
require 'rubygems'
|
6
6
|
require 'rspec'
|
7
|
+
require 'yaml'
|
7
8
|
require 'scrypt'
|
9
|
+
|
10
|
+
# Load shared examples
|
11
|
+
Dir[File.expand_path('support/**/*.rb', __dir__)].each { |f| require f }
|
12
|
+
|
13
|
+
# Load test fixtures
|
14
|
+
TEST_VECTORS = YAML.load_file(File.expand_path('fixtures/test_vectors.yml', __dir__)).freeze
|
15
|
+
|
16
|
+
RSpec.configure do |config|
|
17
|
+
# Use documentation format for better output
|
18
|
+
config.default_formatter = 'doc' if config.files_to_run.one?
|
19
|
+
|
20
|
+
# Run specs in random order to surface order dependencies
|
21
|
+
config.order = :random
|
22
|
+
|
23
|
+
# Seed global randomization in this process using the `--seed` CLI option
|
24
|
+
Kernel.srand config.seed
|
25
|
+
|
26
|
+
# Allow more verbose output when running a single file
|
27
|
+
config.filter_run_when_matching :focus
|
28
|
+
|
29
|
+
# Enable expect syntax (recommended)
|
30
|
+
config.expect_with :rspec do |expectations|
|
31
|
+
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
|
32
|
+
# Disable deprecated should syntax
|
33
|
+
expectations.syntax = :expect
|
34
|
+
end
|
35
|
+
|
36
|
+
# Configure mocks
|
37
|
+
config.mock_with :rspec do |mocks|
|
38
|
+
mocks.verify_partial_doubles = true
|
39
|
+
end
|
40
|
+
|
41
|
+
# Enable shared context metadata behavior
|
42
|
+
config.shared_context_metadata_behavior = :apply_to_host_groups
|
43
|
+
|
44
|
+
# Configure warnings and deprecations
|
45
|
+
config.warnings = true
|
46
|
+
config.raise_errors_for_deprecations!
|
47
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Shared examples for SCrypt tests
|
4
|
+
RSpec.shared_examples 'a valid scrypt hash' do
|
5
|
+
it 'has the correct format' do
|
6
|
+
expect(subject).to match(/^[0-9a-z]+\$[0-9a-z]+\$[0-9a-z]+\$[A-Za-z0-9]+\$[A-Za-z0-9]+$/)
|
7
|
+
end
|
8
|
+
|
9
|
+
it 'is a string' do
|
10
|
+
expect(subject).to be_a(String)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
RSpec.shared_examples 'a valid cost string' do
|
15
|
+
it 'has the correct format' do
|
16
|
+
expect(subject).to match(/^[0-9a-z]+\$[0-9a-z]+\$[0-9a-z]+\$$/)
|
17
|
+
end
|
18
|
+
|
19
|
+
it 'is valid according to Engine.valid_cost?' do
|
20
|
+
expect(SCrypt::Engine.valid_cost?(subject)).to be(true)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
RSpec.shared_examples 'a valid salt string' do
|
25
|
+
it 'has the correct format' do
|
26
|
+
expect(subject).to match(/^[0-9a-z]+\$[0-9a-z]+\$[0-9a-z]+\$[A-Za-z0-9]{16,64}$/)
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'is valid according to Engine.valid_salt?' do
|
30
|
+
expect(SCrypt::Engine.valid_salt?(subject)).to be(true)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
RSpec.shared_examples 'proper input validation' do |method, args, error_class, error_message|
|
35
|
+
it "raises #{error_class} for invalid input" do
|
36
|
+
expect { subject.send(method, *args) }.to raise_error(error_class, error_message)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
RSpec.shared_examples 'deterministic output' do |method, args|
|
41
|
+
it 'produces the same output for the same input' do
|
42
|
+
result1 = subject.send(method, *args)
|
43
|
+
result2 = subject.send(method, *args)
|
44
|
+
expect(result1).to eq(result2)
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TestHelpers
|
4
|
+
# Common test data
|
5
|
+
VALID_SECRETS = [
|
6
|
+
'simple_password',
|
7
|
+
'complex_password_123!@#',
|
8
|
+
'',
|
9
|
+
'unicode_tésting',
|
10
|
+
'🔒secure🔑'
|
11
|
+
].freeze
|
12
|
+
|
13
|
+
INVALID_HASH_FORMATS = [
|
14
|
+
'',
|
15
|
+
'invalid',
|
16
|
+
'400$8$d$invalid',
|
17
|
+
'400$8$d$173a8189751c095a29b933789560b73bf17b2e01',
|
18
|
+
'400$8$d$173a8189751c095a29b933789560b73bf17b2e01$'
|
19
|
+
].freeze
|
20
|
+
|
21
|
+
INVALID_SALT_FORMATS = [
|
22
|
+
'',
|
23
|
+
'invalid',
|
24
|
+
'nino',
|
25
|
+
'400$8$d$'
|
26
|
+
].freeze
|
27
|
+
|
28
|
+
# Helper methods
|
29
|
+
def self.generate_test_password(secret = 'test_secret', options = {})
|
30
|
+
default_options = { max_time: 0.05 }
|
31
|
+
SCrypt::Password.create(secret, default_options.merge(options))
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.generate_test_salt(options = {})
|
35
|
+
default_options = { max_time: 0.05 }
|
36
|
+
SCrypt::Engine.generate_salt(default_options.merge(options))
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.reset_calibration
|
40
|
+
SCrypt::Engine.calibrated_cost = nil
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
# Include helper methods in RSpec
|
45
|
+
RSpec.configure do |config|
|
46
|
+
config.include TestHelpers
|
47
|
+
end
|
data.tar.gz.sig
CHANGED
Binary file
|