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.
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Passlib
4
+ # The current version of the Passlib gem.
5
+ # @return [String]
6
+ VERSION = "0.1.0"
7
+ 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