passlib 0.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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +726 -0
- data/lib/passlib/argon2.rb +48 -0
- data/lib/passlib/balloon.rb +36 -0
- data/lib/passlib/bcrypt.rb +48 -0
- data/lib/passlib/bcrypt_sha256.rb +82 -0
- data/lib/passlib/configuration/context.rb +121 -0
- data/lib/passlib/configuration/passlib.rb +17 -0
- data/lib/passlib/configuration.rb +187 -0
- data/lib/passlib/context.rb +76 -0
- data/lib/passlib/internal/dependency.rb +39 -0
- data/lib/passlib/internal/dsl.rb +59 -0
- data/lib/passlib/internal/register.rb +10 -0
- data/lib/passlib/internal.rb +22 -0
- data/lib/passlib/ldap_digest.rb +106 -0
- data/lib/passlib/md5_crypt.rb +152 -0
- data/lib/passlib/password.rb +169 -0
- data/lib/passlib/pbkdf2.rb +133 -0
- data/lib/passlib/phpass.rb +127 -0
- data/lib/passlib/scrypt.rb +89 -0
- data/lib/passlib/sha1_crypt.rb +108 -0
- data/lib/passlib/sha2_crypt.rb +172 -0
- data/lib/passlib/version.rb +7 -0
- data/lib/passlib/yescrypt.rb +31 -0
- data/lib/passlib.rb +91 -0
- metadata +160 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Passlib
|
|
4
|
+
# Handles phpass Portable Hash password hashing.
|
|
5
|
+
#
|
|
6
|
+
# phpass was widely used by PHP applications (WordPress, Drupal, phpBB) as a
|
|
7
|
+
# fallback when bcrypt was unavailable. It applies iterated MD5 with a
|
|
8
|
+
# configurable round count and an 8-character salt, encoding the result in a
|
|
9
|
+
# custom base64 alphabet.
|
|
10
|
+
#
|
|
11
|
+
# Two MCF identifiers are recognized:
|
|
12
|
+
# - +$P$+ — standard phpass portable hash
|
|
13
|
+
# - +$H$+ — phpBB3 variant (identical algorithm, different identifier)
|
|
14
|
+
#
|
|
15
|
+
# New hashes are always produced with the +$P$+ identifier.
|
|
16
|
+
#
|
|
17
|
+
# Hash format: +$P$<rounds_char><salt8><checksum22>+
|
|
18
|
+
#
|
|
19
|
+
# This format is compatible with the
|
|
20
|
+
# {https://passlib.readthedocs.io/en/stable/lib/passlib.hash.phpass.html
|
|
21
|
+
# phpass portable hash} as implemented in Python's passlib.
|
|
22
|
+
#
|
|
23
|
+
# @note phpass is considered a legacy algorithm. It is supported here for
|
|
24
|
+
# verifying existing hashes and migrating users to a stronger scheme. Do
|
|
25
|
+
# not use it for new hashes.
|
|
26
|
+
#
|
|
27
|
+
# @example
|
|
28
|
+
# hash = Passlib::PHPass.create("hunter2")
|
|
29
|
+
# hash.verify("hunter2") # => true
|
|
30
|
+
# hash.to_s # => "$P$H..."
|
|
31
|
+
#
|
|
32
|
+
# @!method self.create(secret, **options)
|
|
33
|
+
# Creates a new phpass hash.
|
|
34
|
+
# @param secret [String] the plaintext password
|
|
35
|
+
# @option options [Integer] :rounds base-2 log of the iteration count,
|
|
36
|
+
# 7–30 (default: 19, i.e. 2^19 = 524 288 iterations)
|
|
37
|
+
# @option options [String] :salt custom 8-character salt using the phpass
|
|
38
|
+
# alphabet +./0-9A-Za-z+ (normally auto-generated)
|
|
39
|
+
# @return [PHPass]
|
|
40
|
+
class PHPass < Password
|
|
41
|
+
register mcf: %w[P H]
|
|
42
|
+
options :rounds, :salt
|
|
43
|
+
|
|
44
|
+
# Alphabet used for the rounds character, salt, and checksum encoding.
|
|
45
|
+
ITOA64 = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
46
|
+
|
|
47
|
+
DEFAULT_ROUNDS = 19
|
|
48
|
+
MIN_ROUNDS = 7
|
|
49
|
+
MAX_ROUNDS = 30
|
|
50
|
+
|
|
51
|
+
FORMAT = /\A\$[PH]\$([.\/0-9A-Za-z])([.\/0-9A-Za-z]{8})([.\/0-9A-Za-z]{22})\z/
|
|
52
|
+
private_constant :FORMAT
|
|
53
|
+
|
|
54
|
+
# Verifies +secret+ against the stored hash.
|
|
55
|
+
#
|
|
56
|
+
# Overrides the default +create_comparable+ approach so that +$H$+ hashes
|
|
57
|
+
# (phpBB3 variant) are verified correctly: both identifiers produce the same
|
|
58
|
+
# 22-character checksum, so only that portion is compared.
|
|
59
|
+
#
|
|
60
|
+
# @param secret [String] the plaintext password
|
|
61
|
+
# @return [Boolean]
|
|
62
|
+
def verify(secret)
|
|
63
|
+
Passlib.secure_compare(@string[-22..], phpass_digest(secret))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Boolean] +true+ if the stored iteration count differs from the
|
|
67
|
+
# configured target
|
|
68
|
+
def upgrade?
|
|
69
|
+
@rounds != (config.rounds || DEFAULT_ROUNDS).clamp(MIN_ROUNDS, MAX_ROUNDS)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def load(string)
|
|
75
|
+
m = FORMAT.match(string) or raise ArgumentError, "invalid phpass hash: #{string.inspect}"
|
|
76
|
+
@rounds = ITOA64.index(m[1])
|
|
77
|
+
@salt = m[2]
|
|
78
|
+
string
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def create(secret)
|
|
82
|
+
@rounds = (config.rounds || DEFAULT_ROUNDS).clamp(MIN_ROUNDS, MAX_ROUNDS)
|
|
83
|
+
@salt = config.salt || generate_salt
|
|
84
|
+
"$P$#{ITOA64[@rounds]}#{@salt}#{phpass_digest(secret)}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Computes the 22-character phpass checksum.
|
|
88
|
+
#
|
|
89
|
+
# 1. +digest = MD5(salt + UTF-8 password)+
|
|
90
|
+
# 2. Repeat +2^rounds+ times: +digest = MD5(digest + UTF-8 password)+
|
|
91
|
+
# 3. Encode the 16-byte result with the phpass base64 encoding
|
|
92
|
+
def phpass_digest(secret)
|
|
93
|
+
pw = secret.encode("UTF-8").b
|
|
94
|
+
digest = OpenSSL::Digest::MD5.digest(@salt + pw)
|
|
95
|
+
(1 << @rounds).times { digest = OpenSSL::Digest::MD5.digest(digest + pw) }
|
|
96
|
+
encode64(digest)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Encodes +bytes+ using the phpass custom base64 scheme.
|
|
100
|
+
#
|
|
101
|
+
# Groups bytes in sets of three and packs them into four 6-bit characters
|
|
102
|
+
# using LSB-first bit ordering (unlike standard base64 which is MSB-first).
|
|
103
|
+
# For 16 bytes this yields exactly 22 characters; for 6 bytes, 8 characters.
|
|
104
|
+
def encode64(bytes)
|
|
105
|
+
result = +""
|
|
106
|
+
i = 0
|
|
107
|
+
while i < bytes.bytesize
|
|
108
|
+
v = bytes.getbyte(i)
|
|
109
|
+
result << ITOA64[v & 0x3f]
|
|
110
|
+
v |= bytes.getbyte(i + 1) << 8 if i + 1 < bytes.bytesize
|
|
111
|
+
result << ITOA64[(v >> 6) & 0x3f]
|
|
112
|
+
break if i + 1 >= bytes.bytesize
|
|
113
|
+
v |= bytes.getbyte(i + 2) << 16 if i + 2 < bytes.bytesize
|
|
114
|
+
result << ITOA64[(v >> 12) & 0x3f]
|
|
115
|
+
break if i + 2 >= bytes.bytesize
|
|
116
|
+
result << ITOA64[(v >> 18) & 0x3f]
|
|
117
|
+
i += 3
|
|
118
|
+
end
|
|
119
|
+
result
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Generates a random 8-character salt by encoding 6 random bytes.
|
|
123
|
+
def generate_salt
|
|
124
|
+
encode64(Internal.random_bytes(6))
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Passlib
|
|
4
|
+
# Handles scrypt password hashing via the {https://rubygems.org/gems/scrypt scrypt} gem.
|
|
5
|
+
#
|
|
6
|
+
# Two hash formats are accepted on load:
|
|
7
|
+
# - Passlib MCF: +$scrypt$ln=<ln>,r=<r>,p=<p>$<salt>$<checksum>+
|
|
8
|
+
# - SCrypt gem: the native hex format produced by the scrypt gem
|
|
9
|
+
# (normalized to MCF on load)
|
|
10
|
+
#
|
|
11
|
+
# New hashes are always produced in the passlib MCF format.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# hash = Passlib::SCrypt.create("hunter2", ln: 14)
|
|
15
|
+
# hash.verify("hunter2") # => true
|
|
16
|
+
# hash.to_s # => "$scrypt$ln=14,r=8,p=1$...$..."
|
|
17
|
+
#
|
|
18
|
+
# @!method self.create(secret, **options)
|
|
19
|
+
# Creates a new scrypt hash.
|
|
20
|
+
# @param secret [String] the plaintext password
|
|
21
|
+
# @option options [Integer] :ln CPU/memory cost as a base-2 log (default: 16,
|
|
22
|
+
# meaning N=65536), mutually exclusive with +:n+
|
|
23
|
+
# @option options [Integer] :n CPU/memory cost as an integer power of two,
|
|
24
|
+
# converted to +:ln+ internally
|
|
25
|
+
# @option options [Integer] :r block size (default: 8)
|
|
26
|
+
# @option options [Integer] :p parallelization factor (default: 1)
|
|
27
|
+
# @option options [String] :salt custom salt as a binary string (default: 16 random bytes)
|
|
28
|
+
# @option options [Integer] :key_len derived key length in bytes (default: 32)
|
|
29
|
+
# @return [SCrypt]
|
|
30
|
+
class SCrypt < Password
|
|
31
|
+
MCF_PATTERN = /\A\$scrypt\$ln=(\d+),r=(\d+),p=(\d+)\$(.+)\$(.+)\z/
|
|
32
|
+
RUBY_PATTERN = /\A([0-9a-f]+)\$([0-9a-f]+)\$([0-9a-f]+)\$([0-9a-f]{16,64})\$([0-9a-f]{32,1024})\Z/
|
|
33
|
+
private_constant :MCF_PATTERN, :RUBY_PATTERN
|
|
34
|
+
|
|
35
|
+
external "scrypt", "~> 3.1"
|
|
36
|
+
options :ln, :n, :r, :p, :salt, :key_len
|
|
37
|
+
register mcf: "scrypt", pattern: RUBY_PATTERN
|
|
38
|
+
|
|
39
|
+
# @param secret [String] the plaintext password to re-hash
|
|
40
|
+
# @return [SCrypt] a new instance hashed with the same salt and parameters
|
|
41
|
+
def create_comparable(secret)
|
|
42
|
+
self.class.create(secret, salt: @salt, ln: @ln, r: @r, p: @p, key_len: @key_len)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def upgrade?
|
|
46
|
+
target_ln = config.n ? Math.log2(config.n).to_i : (config.ln || 16)
|
|
47
|
+
@ln != target_ln
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def create(secret)
|
|
53
|
+
@ln ||= config.n ? Math.log2(config.n).to_i : config.ln || 16
|
|
54
|
+
@r ||= config.r || 8
|
|
55
|
+
@p ||= config.p || 1
|
|
56
|
+
@salt ||= config.salt || Internal.random_bytes(16)
|
|
57
|
+
@key_len ||= config.key_len || 32
|
|
58
|
+
@checksum ||= ::SCrypt::Engine.scrypt(secret, @salt, 2 ** @ln, @r, @p, @key_len)
|
|
59
|
+
"$scrypt$ln=#{@ln},r=#{@r},p=#{@p}$#{Internal.encode64(@salt)}$#{Internal.encode64(@checksum)}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def load(string)
|
|
63
|
+
case string
|
|
64
|
+
when MCF_PATTERN
|
|
65
|
+
# passlib format
|
|
66
|
+
match = Regexp.last_match
|
|
67
|
+
@ln = match[1].to_i
|
|
68
|
+
@r = match[2].to_i
|
|
69
|
+
@p = match[3].to_i
|
|
70
|
+
@salt = Internal.decode64(match[4])
|
|
71
|
+
@checksum = Internal.decode64(match[5])
|
|
72
|
+
@key_len = @checksum.length
|
|
73
|
+
string
|
|
74
|
+
when RUBY_PATTERN
|
|
75
|
+
# scrypt gem format
|
|
76
|
+
match = Regexp.last_match
|
|
77
|
+
@ln = Math.log2(match[1].to_i(16)).to_i
|
|
78
|
+
@r = match[2].to_i(16)
|
|
79
|
+
@p = match[3].to_i(16)
|
|
80
|
+
@salt = [match[4].sub(/^(00)+/, '')].pack('H*')
|
|
81
|
+
@checksum = [match[5].sub(/^(00)+/, '')].pack('H*')
|
|
82
|
+
@key_len = @checksum.length
|
|
83
|
+
create(nil)
|
|
84
|
+
else
|
|
85
|
+
raise UnknownHashFormat, "invalid scrypt hash format"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Passlib
|
|
4
|
+
# Handles SHA1-crypt password hashing (NetBSD's crypt-sha1).
|
|
5
|
+
#
|
|
6
|
+
# SHA1-crypt applies iterated HMAC-SHA1 to derive a password hash. It was
|
|
7
|
+
# designed by Simon Gerraty for NetBSD as an alternative to DES-crypt that
|
|
8
|
+
# supports long passwords. It is a legacy algorithm — supported here for
|
|
9
|
+
# verifying existing hashes and migrating users to a stronger scheme.
|
|
10
|
+
#
|
|
11
|
+
# Hash format: +$sha1$<rounds>$<salt>$<checksum>+
|
|
12
|
+
#
|
|
13
|
+
# - +rounds+ — decimal integer, iteration count (1–4,294,967,295)
|
|
14
|
+
# - +salt+ — 1–64 characters from +./0-9A-Za-z+ (default: 8 random chars)
|
|
15
|
+
# - +checksum+ — 28 characters from +./0-9A-Za-z+
|
|
16
|
+
#
|
|
17
|
+
# This format is compatible with the
|
|
18
|
+
# {https://passlib.readthedocs.io/en/stable/lib/passlib.hash.sha1_crypt.html
|
|
19
|
+
# sha1_crypt} as implemented in Python's passlib.
|
|
20
|
+
#
|
|
21
|
+
# @note SHA1-crypt is a legacy algorithm. It is supported here for
|
|
22
|
+
# verifying existing hashes and migrating users to a stronger scheme. Do
|
|
23
|
+
# not use it for new hashes.
|
|
24
|
+
#
|
|
25
|
+
# @example
|
|
26
|
+
# hash = Passlib::SHA1Crypt.create("hunter2")
|
|
27
|
+
# hash.verify("hunter2") # => true
|
|
28
|
+
# hash.to_s # => "$sha1$480000$...$..."
|
|
29
|
+
#
|
|
30
|
+
# @!method self.create(secret, **options)
|
|
31
|
+
# Creates a new SHA1-crypt hash.
|
|
32
|
+
# @param secret [String] the plaintext password
|
|
33
|
+
# @option options [Integer] :rounds iteration count, 1–4,294,967,295
|
|
34
|
+
# (default: 480,000)
|
|
35
|
+
# @option options [String] :salt custom salt string, up to 64 characters
|
|
36
|
+
# from +./0-9A-Za-z+ (default: 8 random characters)
|
|
37
|
+
# @return [SHA1Crypt]
|
|
38
|
+
class SHA1Crypt < Password
|
|
39
|
+
register :sha1_crypt, mcf: "sha1"
|
|
40
|
+
options :rounds, :salt
|
|
41
|
+
|
|
42
|
+
HASH_CHARS = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
43
|
+
DEFAULT_ROUNDS = 480_000
|
|
44
|
+
MIN_ROUNDS = 1
|
|
45
|
+
MAX_ROUNDS = 4_294_967_295
|
|
46
|
+
MAX_SALT_LEN = 64
|
|
47
|
+
|
|
48
|
+
# Byte-index groups for encoding the 20-byte SHA1 digest into 28 characters.
|
|
49
|
+
# The last group wraps around to reuse byte 0, following the sha1crypt spec.
|
|
50
|
+
ENCODE_OFFSETS = [
|
|
51
|
+
[0, 1, 2], [3, 4, 5], [6, 7, 8], [9, 10, 11],
|
|
52
|
+
[12, 13, 14], [15, 16, 17], [18, 19, 0],
|
|
53
|
+
].freeze
|
|
54
|
+
|
|
55
|
+
LOAD_PATTERN = %r{\A\$sha1\$(\d+)\$([./0-9A-Za-z]{1,64})\$([./0-9A-Za-z]{28})\z}
|
|
56
|
+
|
|
57
|
+
private_constant :HASH_CHARS, :DEFAULT_ROUNDS, :MIN_ROUNDS, :MAX_ROUNDS,
|
|
58
|
+
:MAX_SALT_LEN, :ENCODE_OFFSETS, :LOAD_PATTERN
|
|
59
|
+
|
|
60
|
+
# @param secret [String] the plaintext password to re-hash
|
|
61
|
+
# @return [SHA1Crypt] a new instance hashed with the same salt and rounds
|
|
62
|
+
def create_comparable(secret)
|
|
63
|
+
self.class.create(secret, salt: @salt, rounds: @rounds)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Boolean] +true+ if the stored iteration count differs from the
|
|
67
|
+
# configured target
|
|
68
|
+
def upgrade?
|
|
69
|
+
@rounds != (config.rounds || DEFAULT_ROUNDS).clamp(MIN_ROUNDS, MAX_ROUNDS)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def create(secret)
|
|
75
|
+
@rounds = (config.rounds || DEFAULT_ROUNDS).clamp(MIN_ROUNDS, MAX_ROUNDS)
|
|
76
|
+
@salt = (config.salt || random_salt)[0, MAX_SALT_LEN]
|
|
77
|
+
"$sha1$#{@rounds}$#{@salt}$#{sha1_digest(secret)}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def load(string)
|
|
81
|
+
m = LOAD_PATTERN.match(string) or raise ArgumentError, "invalid sha1_crypt hash: #{string.inspect}"
|
|
82
|
+
@rounds = m[1].to_i
|
|
83
|
+
@salt = m[2]
|
|
84
|
+
string
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def sha1_digest(secret)
|
|
88
|
+
key = secret.encode("UTF-8").b
|
|
89
|
+
digest = OpenSSL::HMAC.digest("SHA1", key, "#{@salt}$sha1$#{@rounds}")
|
|
90
|
+
(@rounds - 1).times { digest = OpenSSL::HMAC.digest("SHA1", key, digest) }
|
|
91
|
+
encode(digest)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Encodes the 20-byte digest into 28 characters using the sha1crypt
|
|
95
|
+
# big-endian 24-bit grouping with wrap-around for the final group.
|
|
96
|
+
def encode(digest)
|
|
97
|
+
b = digest.bytes
|
|
98
|
+
ENCODE_OFFSETS.map { |i, j, k|
|
|
99
|
+
v = (b[i] << 16) | (b[j] << 8) | b[k]
|
|
100
|
+
4.times.map { c = HASH_CHARS[v & 0x3f]; v >>= 6; c }.join
|
|
101
|
+
}.join
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def random_salt
|
|
105
|
+
Internal.random_bytes(6).bytes.map { HASH_CHARS[it & 63] }.join
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Passlib
|
|
4
|
+
# Handles SHA-256 ($5$) and SHA-512 ($6$) crypt password hashing via OpenSSL.
|
|
5
|
+
#
|
|
6
|
+
# Implements the SHA-crypt algorithm as specified by Ulrich Drepper
|
|
7
|
+
# (https://www.akkadia.org/drepper/SHA-crypt.txt). Both 256-bit and 512-bit
|
|
8
|
+
# variants are supported and auto-detected on load.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# hash = Passlib::ShaCrypt.create("hunter2", bits: 512, rounds: 10_000)
|
|
12
|
+
# hash.verify("hunter2") # => true
|
|
13
|
+
# hash.to_s # => "$6$rounds=10000$...$..."
|
|
14
|
+
#
|
|
15
|
+
# @!method self.create(secret, **options)
|
|
16
|
+
# Creates a new SHA-crypt hash.
|
|
17
|
+
# @param secret [String] the plaintext password
|
|
18
|
+
# @option options [Integer] :bits selects the SHA variant — +256+ (SHA-256, MCF id +$5$+) or
|
|
19
|
+
# +512+ (SHA-512, MCF id +$6$+) (default: +512+)
|
|
20
|
+
# @option options [Integer] :rounds number of hashing rounds (default: 535,000 for SHA-256,
|
|
21
|
+
# 656,000 for SHA-512, clamped to 1,000–999,999,999)
|
|
22
|
+
# @option options [String] :salt custom salt string up to 16 characters (default: random)
|
|
23
|
+
# @return [SHA2Crypt]
|
|
24
|
+
class SHA2Crypt < Password
|
|
25
|
+
HASH_CHARS = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
26
|
+
IMPLICIT_ROUNDS = 5_000
|
|
27
|
+
MIN_ROUNDS = 1_000
|
|
28
|
+
MAX_ROUNDS = 999_999_999
|
|
29
|
+
MAX_SALT_LEN = 16
|
|
30
|
+
|
|
31
|
+
VARIANTS = {
|
|
32
|
+
256 => { mcf_id: "5", digest: OpenSSL::Digest::SHA256, block_size: 32, default_rounds: 535_000, checksum_len: 43 },
|
|
33
|
+
512 => { mcf_id: "6", digest: OpenSSL::Digest::SHA512, block_size: 64, default_rounds: 656_000, checksum_len: 86 },
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
# Byte index groups for SHA-crypt base64 encoding.
|
|
37
|
+
# nil entries encode as 0 (padding for the final partial group).
|
|
38
|
+
ENCODE_GROUPS = {
|
|
39
|
+
256 => [
|
|
40
|
+
[0, 10, 20, 4], [21, 1, 11, 4], [12, 22, 2, 4], [3, 13, 23, 4],
|
|
41
|
+
[24, 4, 14, 4], [15, 25, 5, 4], [6, 16, 26, 4], [27, 7, 17, 4],
|
|
42
|
+
[18, 28, 8, 4], [9, 19, 29, 4], [nil, 31, 30, 3],
|
|
43
|
+
].freeze,
|
|
44
|
+
512 => [
|
|
45
|
+
[0, 21, 42, 4], [22, 43, 1, 4], [44, 2, 23, 4], [3, 24, 45, 4],
|
|
46
|
+
[25, 46, 4, 4], [47, 5, 26, 4], [6, 27, 48, 4], [28, 49, 7, 4],
|
|
47
|
+
[50, 8, 29, 4], [9, 30, 51, 4], [31, 52, 10, 4], [53, 11, 32, 4],
|
|
48
|
+
[12, 33, 54, 4], [34, 55, 13, 4], [56, 14, 35, 4], [15, 36, 57, 4],
|
|
49
|
+
[37, 58, 16, 4], [59, 17, 38, 4], [18, 39, 60, 4], [40, 61, 19, 4],
|
|
50
|
+
[62, 20, 41, 4], [nil, nil, 63, 2],
|
|
51
|
+
].freeze,
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
MCF_TO_BITS = { "5" => 256, "6" => 512 }.freeze
|
|
55
|
+
LOAD_PATTERN = %r{\A\$(5|6)\$(?:rounds=(\d+)\$)?([./0-9A-Za-z]{0,16})\$([./0-9A-Za-z]+)\z}
|
|
56
|
+
|
|
57
|
+
private_constant :HASH_CHARS, :IMPLICIT_ROUNDS, :MIN_ROUNDS, :MAX_ROUNDS, :MAX_SALT_LEN,
|
|
58
|
+
:VARIANTS, :ENCODE_GROUPS, :MCF_TO_BITS, :LOAD_PATTERN
|
|
59
|
+
|
|
60
|
+
register :sha2_crypt, mcf: %w[5 6]
|
|
61
|
+
options :bits, :rounds, :salt
|
|
62
|
+
|
|
63
|
+
# @param secret [String] the plaintext password to re-hash
|
|
64
|
+
# @return [SHA2Crypt] a new instance hashed with the same salt, rounds, and bits
|
|
65
|
+
def create_comparable(secret)
|
|
66
|
+
self.class.create(secret, salt: @salt, rounds: @rounds, bits: @bits)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def upgrade?
|
|
70
|
+
@rounds != (config.rounds || VARIANTS[@bits][:default_rounds])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def create(secret)
|
|
76
|
+
@bits = config.bits || 512
|
|
77
|
+
variant = VARIANTS[@bits] or raise ArgumentError, "invalid bits: #{@bits.inspect}, must be 256 or 512"
|
|
78
|
+
rounds = config.rounds || variant[:default_rounds]
|
|
79
|
+
@rounds = rounds.clamp(MIN_ROUNDS, MAX_ROUNDS)
|
|
80
|
+
@salt = (config.salt || random_salt)[0, MAX_SALT_LEN]
|
|
81
|
+
crypt(secret.to_s.b, @salt.b, @rounds, variant)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def load(string)
|
|
85
|
+
match = LOAD_PATTERN.match(string) or raise UnknownHashFormat, "invalid sha_crypt hash: #{string.inspect}"
|
|
86
|
+
@bits = MCF_TO_BITS[match[1]]
|
|
87
|
+
variant = VARIANTS[@bits]
|
|
88
|
+
raise UnknownHashFormat, "invalid sha_crypt hash: #{string.inspect}" unless match[4].length == variant[:checksum_len]
|
|
89
|
+
@rounds = match[2] ? match[2].to_i : IMPLICIT_ROUNDS
|
|
90
|
+
@salt = match[3]
|
|
91
|
+
string
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def random_salt
|
|
95
|
+
Internal.random_bytes(MAX_SALT_LEN).bytes.map { HASH_CHARS[it & 63] }.join
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def crypt(password, salt, rounds, variant)
|
|
99
|
+
digest_class = variant[:digest]
|
|
100
|
+
block_size = variant[:block_size]
|
|
101
|
+
mcf_id = variant[:mcf_id]
|
|
102
|
+
|
|
103
|
+
# Steps 1-3: start digest A, add password and salt
|
|
104
|
+
ctx_a = digest_class.new
|
|
105
|
+
ctx_a.update(password)
|
|
106
|
+
ctx_a.update(salt)
|
|
107
|
+
|
|
108
|
+
# Steps 4-8: digest B = digest(password + salt + password)
|
|
109
|
+
ctx_b = digest_class.new
|
|
110
|
+
ctx_b.update(password)
|
|
111
|
+
ctx_b.update(salt)
|
|
112
|
+
ctx_b.update(password)
|
|
113
|
+
digest_b = ctx_b.digest
|
|
114
|
+
|
|
115
|
+
# Steps 9-10: add digest B to A in block_size-byte blocks, then the remainder
|
|
116
|
+
plen = password.bytesize
|
|
117
|
+
(plen / block_size).times { ctx_a.update(digest_b) }
|
|
118
|
+
ctx_a.update(digest_b.b[0, plen % block_size]) if (plen % block_size) > 0
|
|
119
|
+
|
|
120
|
+
# Step 11: for each bit of plen (LSB first): 1-bit → add digest B, 0-bit → add password
|
|
121
|
+
len = plen
|
|
122
|
+
while len > 0
|
|
123
|
+
ctx_a.update(len.odd? ? digest_b : password)
|
|
124
|
+
len >>= 1
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Step 12: finish digest A
|
|
128
|
+
digest_a = ctx_a.digest
|
|
129
|
+
|
|
130
|
+
# Steps 13-16: P sequence — repeat password plen times through digest, tile to plen bytes
|
|
131
|
+
ctx_p = digest_class.new
|
|
132
|
+
plen.times { ctx_p.update(password) }
|
|
133
|
+
digest_p = ctx_p.digest
|
|
134
|
+
p_str = (digest_p * ((plen / block_size) + 1)).b[0, plen]
|
|
135
|
+
|
|
136
|
+
# Steps 17-20: S sequence — repeat salt (16 + first byte of digest A) times, tile to slen bytes
|
|
137
|
+
slen = salt.bytesize
|
|
138
|
+
ctx_s = digest_class.new
|
|
139
|
+
(16 + digest_a.getbyte(0)).times { ctx_s.update(salt) }
|
|
140
|
+
digest_s = ctx_s.digest
|
|
141
|
+
s_str = (digest_s * ((slen / block_size) + 1)).b[0, slen]
|
|
142
|
+
|
|
143
|
+
# Step 21: main loop — rounds iterations mixing C, P, and S
|
|
144
|
+
c = digest_a
|
|
145
|
+
rounds.times do |i|
|
|
146
|
+
ctx_c = digest_class.new
|
|
147
|
+
ctx_c.update(i.odd? ? p_str : c)
|
|
148
|
+
ctx_c.update(s_str) unless (i % 3).zero?
|
|
149
|
+
ctx_c.update(p_str) unless (i % 7).zero?
|
|
150
|
+
ctx_c.update(i.odd? ? c : p_str)
|
|
151
|
+
c = ctx_c.digest
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Step 22: encode output with variant-specific byte ordering
|
|
155
|
+
encoded = encode(c)
|
|
156
|
+
rounds == IMPLICIT_ROUNDS ? "$#{mcf_id}$#{salt}$#{encoded}" : "$#{mcf_id}$rounds=#{rounds}$#{salt}$#{encoded}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def encode(digest)
|
|
160
|
+
b = digest.bytes
|
|
161
|
+
ENCODE_GROUPS[@bits].map { |w2, w1, w0, n|
|
|
162
|
+
b64_group(w2 ? b[w2] : 0, w1 ? b[w1] : 0, w0 ? b[w0] : 0, n)
|
|
163
|
+
}.join
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Encode (w2, w1, w0) as n characters from HASH_CHARS, extracting 6-bit chunks LSB-first
|
|
167
|
+
def b64_group(w2, w1, w0, n)
|
|
168
|
+
v = (w2 << 16) | (w1 << 8) | w0
|
|
169
|
+
n.times.map { c = HASH_CHARS[v & 0x3f]; v >>= 6; c }.join
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Passlib
|
|
4
|
+
# Handles yescrypt password hashing via the
|
|
5
|
+
# {https://rubygems.org/gems/yescrypt yescrypt} gem.
|
|
6
|
+
#
|
|
7
|
+
# Hash format: +$y$jAU.../.....0$<salt>$<checksum>+
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# hash = Passlib::Yescrypt.create("hunter2")
|
|
11
|
+
# hash.verify("hunter2") # => true
|
|
12
|
+
#
|
|
13
|
+
# @!method self.create(secret, **options)
|
|
14
|
+
# Creates a new yescrypt hash.
|
|
15
|
+
# @param secret [String] the plaintext password
|
|
16
|
+
# @option options [Integer] :n_log2 base-2 log of the memory/CPU cost factor
|
|
17
|
+
# @option options [Integer] :r block size
|
|
18
|
+
# @option options [Integer] :p parallelization factor
|
|
19
|
+
# @option options [Integer] :t additional time parameter
|
|
20
|
+
# @option options [Integer] :flags algorithm flags bitmask
|
|
21
|
+
# @option options [String] :salt custom salt (normally auto-generated)
|
|
22
|
+
# @return [Yescrypt]
|
|
23
|
+
class Yescrypt < Password
|
|
24
|
+
register mcf: "y"
|
|
25
|
+
external "yescrypt", ">= 0.1.1"
|
|
26
|
+
options :n_log2, :r, :p, :t, :flags, :salt
|
|
27
|
+
def verify(secret) = ::Yescrypt.verify(secret, string)
|
|
28
|
+
def create(secret) = ::Yescrypt.create(secret, **config)
|
|
29
|
+
def upgrade? = !::Yescrypt.cost_matches?(string, **config)
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/passlib.rb
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "concurrent/map"
|
|
5
|
+
|
|
6
|
+
# Top-level namespace for the Passlib gem.
|
|
7
|
+
#
|
|
8
|
+
# Passlib is an algorithm-agnostic password hashing library. It provides a
|
|
9
|
+
# unified interface for creating and verifying password hashes across many
|
|
10
|
+
# supported algorithms, and auto-detects the algorithm from any stored hash
|
|
11
|
+
# string.
|
|
12
|
+
#
|
|
13
|
+
# @example Verifying a stored hash
|
|
14
|
+
# Passlib.verify("hunter2", "$2a$12$...") # => true
|
|
15
|
+
#
|
|
16
|
+
# @example Loading an existing hash and verifying
|
|
17
|
+
# hash = Passlib.load("$2a$12$...")
|
|
18
|
+
# hash.verify("hunter2") # => true
|
|
19
|
+
#
|
|
20
|
+
# @example Creating a hash with a specific algorithm
|
|
21
|
+
# Passlib::BCrypt.create("hunter2", cost: 12).to_s # => "$2a$12$..."
|
|
22
|
+
#
|
|
23
|
+
# @see Password
|
|
24
|
+
# @see Configuration
|
|
25
|
+
module Passlib
|
|
26
|
+
# Base class for all Passlib errors.
|
|
27
|
+
Error ||= Class.new(StandardError)
|
|
28
|
+
|
|
29
|
+
# Raised when a requested algorithm is not supported or its dependency gem is not installed.
|
|
30
|
+
UnsupportedAlgorithm ||= Class.new(Error)
|
|
31
|
+
|
|
32
|
+
# Raised when an algorithm identifier is not recognized.
|
|
33
|
+
UnknownAlgorithm ||= Class.new(UnsupportedAlgorithm)
|
|
34
|
+
|
|
35
|
+
# Raised when a hash string cannot be parsed as any known format.
|
|
36
|
+
UnknownHashFormat ||= Class.new(UnsupportedAlgorithm)
|
|
37
|
+
|
|
38
|
+
# Raised when a required gem dependency is not installed.
|
|
39
|
+
MissingDependency ||= Class.new(UnsupportedAlgorithm)
|
|
40
|
+
|
|
41
|
+
autoload :Configuration, "passlib/configuration"
|
|
42
|
+
autoload :Internal, "passlib/internal"
|
|
43
|
+
autoload :Password, "passlib/password"
|
|
44
|
+
|
|
45
|
+
# Poor man's Zeitwerk
|
|
46
|
+
Dir.glob("passlib/*.rb", base: __dir__) { require_relative it }
|
|
47
|
+
|
|
48
|
+
extend Configuration::Context
|
|
49
|
+
extend self
|
|
50
|
+
|
|
51
|
+
# Performs a constant-time string comparison to prevent timing attacks.
|
|
52
|
+
#
|
|
53
|
+
# Returns +false+ immediately—without leaking length information through
|
|
54
|
+
# timing—when either argument does not respond to +#to_str+.
|
|
55
|
+
# Returns +false+ when the byte lengths differ, also in constant time.
|
|
56
|
+
#
|
|
57
|
+
# @param trusted [#to_str] the expected (stored) value
|
|
58
|
+
# @param untrusted [#to_str] the candidate value to compare against +trusted+
|
|
59
|
+
# @return [Boolean] +true+ if both strings are byte-for-byte identical
|
|
60
|
+
def secure_compare(trusted, untrusted)
|
|
61
|
+
return false unless trusted.respond_to? :to_str and trusted = trusted.to_str.b
|
|
62
|
+
return false unless untrusted.respond_to? :to_str and untrusted = untrusted.to_str.b
|
|
63
|
+
|
|
64
|
+
# avoid ability for attacker to guess length of string by timing attack
|
|
65
|
+
comparable = trusted[0, untrusted.bytesize].ljust(untrusted.bytesize, "\0".b)
|
|
66
|
+
result = OpenSSL.fixed_length_secure_compare(comparable, untrusted)
|
|
67
|
+
trusted.bytesize == untrusted.bytesize and result
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Looks up a password algorithm class by identifier.
|
|
71
|
+
#
|
|
72
|
+
# @param key [Symbol, String, Class] algorithm identifier (e.g. +:bcrypt+, +"bcrypt"+)
|
|
73
|
+
# or a {Password} subclass (returned as-is)
|
|
74
|
+
# @return [Class<Password>, nil] the corresponding {Password} subclass,
|
|
75
|
+
# or +nil+ if the identifier is not recognized
|
|
76
|
+
def [](key)
|
|
77
|
+
return key if key.is_a?(Class) and key <= Password
|
|
78
|
+
Internal::Register::IDENTIFIERS[key.to_sym]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Returns whether the given algorithm is available (i.e. its dependency gem is installed).
|
|
82
|
+
#
|
|
83
|
+
# @param algorithm [Symbol, String, Class] algorithm identifier or {Password} subclass
|
|
84
|
+
# @return [Boolean, nil] +true+ if available, +false+ if the dependency is missing,
|
|
85
|
+
# +nil+ if the algorithm identifier is not recognized at all
|
|
86
|
+
def available?(algorithm) = self[algorithm]&.available?
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def base_config = nil
|
|
91
|
+
end
|