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,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Passlib::Internal
|
|
4
|
+
module DSL
|
|
5
|
+
Config = Passlib::Configuration::Passlib
|
|
6
|
+
|
|
7
|
+
def identifier(value = nil)
|
|
8
|
+
@identifier = value if value
|
|
9
|
+
@identifier ||= name[/\w+$/].downcase.to_sym
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def register(*keys, mcf: nil, pattern: nil)
|
|
15
|
+
if keys.empty?
|
|
16
|
+
keys = [identifier]
|
|
17
|
+
else
|
|
18
|
+
@identifier ||= keys.first
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
keys.each { Register::IDENTIFIERS[it] = self if it }
|
|
22
|
+
Array(mcf).each { Register::MFC_IDS[it.to_s] = self if it }
|
|
23
|
+
Array(pattern).each { Register::PATTERNS[it] = self if it }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def external(*) = extend Dependency[*]
|
|
27
|
+
|
|
28
|
+
def config(input, **options)
|
|
29
|
+
case input
|
|
30
|
+
when configuration_class then base_config = input
|
|
31
|
+
when Hash then options, base_config = input.merge(options), config_base
|
|
32
|
+
when Config then base_config = input.respond_to?(identifier) ? input.public_send(identifier) : input
|
|
33
|
+
when nil then base_config = config_base
|
|
34
|
+
else raise ArgumentError, "invalid configuration: #{input.inspect}"
|
|
35
|
+
end
|
|
36
|
+
return base_config if options.empty?
|
|
37
|
+
configuration_class.new(base_config, options)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def config_base
|
|
41
|
+
Passlib.configuration.respond_to?(identifier) ? Passlib.configuration.public_send(identifier) : Passlib.configuration
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def configuration_class
|
|
45
|
+
@configuration_class ||= begin
|
|
46
|
+
demodulize = name[/\w+$/]
|
|
47
|
+
unless Passlib::Configuration.const_defined?(demodulize, false)
|
|
48
|
+
Passlib::Configuration.const_set(demodulize, Class.new(Passlib::Configuration))
|
|
49
|
+
end
|
|
50
|
+
config_class = Passlib::Configuration.const_get(demodulize)
|
|
51
|
+
Config.option(identifier, config_class.new)
|
|
52
|
+
config_class
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def option(...) = configuration_class.option(...)
|
|
57
|
+
def options(...) = configuration_class.options(...)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Passlib
|
|
4
|
+
module Internal
|
|
5
|
+
Dir.glob("internal/*.rb", base: __dir__) { require_relative it }
|
|
6
|
+
extend self
|
|
7
|
+
|
|
8
|
+
def decode64(input) = input.unpack1("m")
|
|
9
|
+
def encode64(input) = [input].pack("m0").sub(/=+\n*\z/, "")
|
|
10
|
+
def encode_ab64(data) = [data].pack("m0").delete("=").tr("+", ".")
|
|
11
|
+
def decode_ab64(str) = (str.tr(".", "+") + "=" * ((-str.length) % 4)).unpack1("m")
|
|
12
|
+
def random_bytes(size) = OpenSSL::Random.random_bytes(size)
|
|
13
|
+
|
|
14
|
+
def mcf_params(string)
|
|
15
|
+
string
|
|
16
|
+
.scan(/(?<=\$|,)([a-z]+)=([^,\$]+)(?=\$|,)/)
|
|
17
|
+
.to_h { [_1.to_sym, _2 =~ /^\d+$/ ? _2.to_i : _2] }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private_constant :Internal
|
|
22
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Passlib
|
|
4
|
+
# Handles LDAP RFC 2307 digest password hashes.
|
|
5
|
+
#
|
|
6
|
+
# Supports plain and salted variants for MD5, SHA-1, SHA-256, SHA-512,
|
|
7
|
+
# SHA3-256, and SHA3-512. MD5 and SHA-1 hashes may be stored in hex encoding
|
|
8
|
+
# (as produced by some LDAP implementations) or standard base64, both are
|
|
9
|
+
# detected automatically on load and preserved on round-trips.
|
|
10
|
+
#
|
|
11
|
+
# Scheme names and their corresponding LDAP prefix:
|
|
12
|
+
# - +MD5+ / +SMD5+ (salted)
|
|
13
|
+
# - +SHA+ / +SSHA+ (salted)
|
|
14
|
+
# - +SHA256+ / +SSHA256+ (salted)
|
|
15
|
+
# - +SHA512+ / +SSHA512+ (salted)
|
|
16
|
+
# - +SHA3-256+ / +SSHA3-256+ (salted)
|
|
17
|
+
# - +SHA3-512+ / +SSHA3-512+ (salted)
|
|
18
|
+
#
|
|
19
|
+
# Hash format: +{SSHA512}base64data+ (or +{MD5}hexdata+ for hex-encoded MD5/SHA)
|
|
20
|
+
#
|
|
21
|
+
# @example
|
|
22
|
+
# hash = Passlib::LdapDigest.create("hunter2", variant: "SSHA512")
|
|
23
|
+
# hash.verify("hunter2") # => true
|
|
24
|
+
# hash.to_s # => "{SSHA512}..."
|
|
25
|
+
#
|
|
26
|
+
# @!method self.create(secret, **options)
|
|
27
|
+
# Creates a new LDAP digest hash.
|
|
28
|
+
# @param secret [String] the plaintext password
|
|
29
|
+
# @option options [String, Symbol] :variant LDAP scheme name — one of +"MD5"+, +"SMD5"+,
|
|
30
|
+
# +"SHA"+, +"SSHA"+, +"SHA256"+, +"SSHA256"+, +"SHA512"+, +"SSHA512"+,
|
|
31
|
+
# +"SHA3-256"+, +"SSHA3-256"+, +"SHA3-512"+, +"SSHA3-512"+;
|
|
32
|
+
# case-insensitive, symbols accepted, underscores may be used instead of dashes
|
|
33
|
+
# (default: +"SSHA512"+)
|
|
34
|
+
# @option options [String] :salt raw binary salt, only used for salted schemes
|
|
35
|
+
# (default: random, 4 bytes for MD5/SHA-1, 8 bytes for others)
|
|
36
|
+
# @option options [Boolean] :hex encode the output as hex instead of base64,
|
|
37
|
+
# only applicable to MD5 and SHA schemes (default: false)
|
|
38
|
+
# @return [LdapDigest]
|
|
39
|
+
class LdapDigest < Password
|
|
40
|
+
SCHEMES = {
|
|
41
|
+
"MD5" => { digest: "MD5", size: 16, salted: false },
|
|
42
|
+
"SHA" => { digest: "SHA1", size: 20, salted: false },
|
|
43
|
+
"SHA256" => { digest: "SHA256", size: 32, salted: false },
|
|
44
|
+
"SHA512" => { digest: "SHA512", size: 64, salted: false },
|
|
45
|
+
"SHA3-256" => { digest: "SHA3-256", size: 32, salted: false },
|
|
46
|
+
"SHA3-512" => { digest: "SHA3-512", size: 64, salted: false },
|
|
47
|
+
"SMD5" => { digest: "MD5", size: 16, salted: true, salt_size: 4 },
|
|
48
|
+
"SSHA" => { digest: "SHA1", size: 20, salted: true, salt_size: 4 },
|
|
49
|
+
"SSHA256" => { digest: "SHA256", size: 32, salted: true, salt_size: 8 },
|
|
50
|
+
"SSHA512" => { digest: "SHA512", size: 64, salted: true, salt_size: 8 },
|
|
51
|
+
"SSHA3-256" => { digest: "SHA3-256", size: 32, salted: true, salt_size: 8 },
|
|
52
|
+
"SSHA3-512" => { digest: "SHA3-512", size: 64, salted: true, salt_size: 8 },
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
LOAD_PATTERN = /\A\{([A-Z0-9-]+)\}([A-Za-z0-9+\/=]+)\z/i
|
|
56
|
+
DEFAULT = "SSHA512"
|
|
57
|
+
HEX_SCHEMES = %w[MD5 SHA].freeze
|
|
58
|
+
|
|
59
|
+
private_constant :SCHEMES, :LOAD_PATTERN, :DEFAULT, :HEX_SCHEMES
|
|
60
|
+
|
|
61
|
+
# Register all scheme names (including hyphenated SHA3 variants) so the
|
|
62
|
+
# updated LDAP auto-detection regex can match them directly.
|
|
63
|
+
SCHEMES.each_key { Internal::Register::LDAP_IDS[_1] = self }
|
|
64
|
+
register :ldap_digest
|
|
65
|
+
|
|
66
|
+
options :variant, :salt, :hex
|
|
67
|
+
|
|
68
|
+
# @param secret [String] the plaintext password to re-hash
|
|
69
|
+
# @return [LdapDigest] a new instance hashed with the same scheme, salt, and encoding
|
|
70
|
+
def create_comparable(secret) = self.class.create(secret, variant: @variant, salt: @salt, hex: @hex)
|
|
71
|
+
|
|
72
|
+
def upgrade?
|
|
73
|
+
configured = normalize_variant(config.variant)
|
|
74
|
+
@variant != configured
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def normalize_variant(input)
|
|
80
|
+
return DEFAULT unless input
|
|
81
|
+
input.to_s.upcase.tr("_", "-")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def create(secret)
|
|
85
|
+
@variant = normalize_variant(config.variant)
|
|
86
|
+
scheme = SCHEMES[@variant] or raise ArgumentError, "unknown LDAP scheme: #{@variant.inspect}"
|
|
87
|
+
@salt = config.salt || (scheme[:salted] ? Internal.random_bytes(scheme[:salt_size]) : nil)
|
|
88
|
+
@hex = config.hex
|
|
89
|
+
checksum = OpenSSL::Digest.digest(scheme[:digest], secret.to_s.b + @salt.to_s.b)
|
|
90
|
+
data = scheme[:salted] ? checksum + @salt : checksum
|
|
91
|
+
@hex ? "{#{@variant}}#{data.unpack1("H*")}" : "{#{@variant}}#{[data].pack("m0")}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def load(string)
|
|
95
|
+
m = LOAD_PATTERN.match(string) or raise UnknownHashFormat, "invalid LDAP hash: #{string.inspect}"
|
|
96
|
+
@variant = normalize_variant(m[1])
|
|
97
|
+
scheme = SCHEMES[@variant] or raise UnknownHashFormat, "unknown LDAP scheme: #{@variant.inspect}"
|
|
98
|
+
@hex = HEX_SCHEMES.include?(@variant) &&
|
|
99
|
+
m[2].length == scheme[:size] * 2 &&
|
|
100
|
+
m[2].match?(/\A[0-9a-f]+\z/i)
|
|
101
|
+
raw = @hex ? [m[2]].pack("H*") : m[2].unpack1("m")
|
|
102
|
+
@salt = raw[scheme[:size]..] if scheme[:salted]
|
|
103
|
+
string
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Passlib
|
|
4
|
+
# Handles MD5-crypt and Apache MD5-crypt password hashing.
|
|
5
|
+
#
|
|
6
|
+
# MD5-crypt was designed by Poul-Henning Kamp for FreeBSD in 1994. It runs
|
|
7
|
+
# 1000 rounds of a complex MD5-based mixing function and was widely deployed
|
|
8
|
+
# on Linux systems as the default password scheme. Apache's variant is
|
|
9
|
+
# identical except for using a different MCF identifier.
|
|
10
|
+
#
|
|
11
|
+
# Two MCF identifiers are recognized:
|
|
12
|
+
# - +$1$+ — standard MD5-crypt (FreeBSD/Linux)
|
|
13
|
+
# - +$apr1$+ — Apache APR variant (functionally identical, different prefix)
|
|
14
|
+
#
|
|
15
|
+
# New hashes created via +MD5Crypt.create+ use +$1$+. To create APR hashes,
|
|
16
|
+
# pass +variant: :apr+.
|
|
17
|
+
#
|
|
18
|
+
# Hash format: +$1$<salt>$<checksum>+ or +$apr1$<salt>$<checksum>+
|
|
19
|
+
#
|
|
20
|
+
# - +salt+ — 0-8 characters from +./0-9A-Za-z+ (default: 8 random characters)
|
|
21
|
+
# - +checksum+ — 22 characters from +./0-9A-Za-z+
|
|
22
|
+
#
|
|
23
|
+
# This format is compatible with the
|
|
24
|
+
# {https://passlib.readthedocs.io/en/stable/lib/passlib.hash.md5_crypt.html
|
|
25
|
+
# md5_crypt} and
|
|
26
|
+
# {https://passlib.readthedocs.io/en/stable/lib/passlib.hash.apr_md5_crypt.html
|
|
27
|
+
# apr_md5_crypt} as implemented in Python's passlib.
|
|
28
|
+
#
|
|
29
|
+
# @note MD5-crypt is a legacy algorithm with a fixed, low round count.
|
|
30
|
+
# It is supported here for verifying existing hashes and migrating users
|
|
31
|
+
# to a stronger scheme. Do not use it for new hashes.
|
|
32
|
+
#
|
|
33
|
+
# @example
|
|
34
|
+
# hash = Passlib::MD5Crypt.create("hunter2")
|
|
35
|
+
# hash.verify("hunter2") # => true
|
|
36
|
+
# hash.to_s # => "$1$...$..."
|
|
37
|
+
#
|
|
38
|
+
# apr = Passlib::MD5Crypt.create("hunter2", variant: :apr)
|
|
39
|
+
# apr.to_s # => "$apr1$...$..."
|
|
40
|
+
#
|
|
41
|
+
# @!method self.create(secret, **options)
|
|
42
|
+
# Creates a new MD5-crypt hash.
|
|
43
|
+
# @param secret [String] the plaintext password
|
|
44
|
+
# @option options [Symbol] :variant selects the MCF identifier: +:standard+
|
|
45
|
+
# (default, produces +$1$+) or +:apr+ (produces +$apr1$+)
|
|
46
|
+
# @option options [String] :salt custom salt string, 0-8 characters from
|
|
47
|
+
# +./0-9A-Za-z+ (default: 8 random characters)
|
|
48
|
+
# @return [MD5Crypt]
|
|
49
|
+
class MD5Crypt < Password
|
|
50
|
+
register :md5_crypt, mcf: %w[1 apr1]
|
|
51
|
+
options :variant, :salt
|
|
52
|
+
|
|
53
|
+
HASH_CHARS = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
54
|
+
MAX_SALT_LEN = 8
|
|
55
|
+
ROUNDS = 1000
|
|
56
|
+
|
|
57
|
+
MAGIC = { standard: "$1$", apr: "$apr1$" }.freeze
|
|
58
|
+
|
|
59
|
+
# Byte-index groups for encoding the 16-byte MD5 digest into 22 characters.
|
|
60
|
+
# The ordering matches the original FreeBSD md5crypt implementation by
|
|
61
|
+
# Poul-Henning Kamp. The final group encodes only one byte as 2 characters.
|
|
62
|
+
ENCODE_OFFSETS = [
|
|
63
|
+
[0, 6, 12, 4], [1, 7, 13, 4], [2, 8, 14, 4],
|
|
64
|
+
[3, 9, 15, 4], [4, 10, 5, 4], [nil, nil, 11, 2],
|
|
65
|
+
].freeze
|
|
66
|
+
|
|
67
|
+
LOAD_PATTERN = %r{\A(\$1\$|\$apr1\$)([./0-9A-Za-z]{0,8})\$([./0-9A-Za-z]{22})\z}
|
|
68
|
+
|
|
69
|
+
private_constant :HASH_CHARS, :MAX_SALT_LEN, :ROUNDS, :MAGIC,
|
|
70
|
+
:ENCODE_OFFSETS, :LOAD_PATTERN
|
|
71
|
+
|
|
72
|
+
# @param secret [String] the plaintext password to re-hash
|
|
73
|
+
# @return [MD5Crypt] a new instance hashed with the same salt and variant
|
|
74
|
+
def create_comparable(secret)
|
|
75
|
+
self.class.create(secret, salt: @salt, variant: @variant)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# MD5-crypt uses a fixed round count of 1000, so upgrade? is always false.
|
|
79
|
+
# @return [Boolean] always +false+
|
|
80
|
+
def upgrade?
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def create(secret)
|
|
87
|
+
@variant = config.variant || :standard
|
|
88
|
+
@magic = MAGIC.fetch(@variant) { raise ArgumentError, "unknown md5_crypt variant: #{@variant.inspect}" }
|
|
89
|
+
@salt = (config.salt || random_salt)[0, MAX_SALT_LEN]
|
|
90
|
+
"#{@magic}#{@salt}$#{md5_digest(secret)}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def load(string)
|
|
94
|
+
m = LOAD_PATTERN.match(string) or raise ArgumentError, "invalid md5_crypt hash: #{string.inspect}"
|
|
95
|
+
@magic = m[1]
|
|
96
|
+
@variant = MAGIC.key(@magic)
|
|
97
|
+
@salt = m[2]
|
|
98
|
+
string
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def md5_digest(secret)
|
|
102
|
+
pw = secret.encode("UTF-8").b
|
|
103
|
+
s = @salt.b
|
|
104
|
+
mag = @magic.b
|
|
105
|
+
|
|
106
|
+
# Digest B = MD5(password + salt + password)
|
|
107
|
+
digest_b = OpenSSL::Digest::MD5.digest(pw + s + pw)
|
|
108
|
+
|
|
109
|
+
# Digest A = MD5(password + magic + salt + tiled_digest_b + bit_select)
|
|
110
|
+
ctx_a = OpenSSL::Digest::MD5.new
|
|
111
|
+
ctx_a.update(pw)
|
|
112
|
+
ctx_a.update(mag)
|
|
113
|
+
ctx_a.update(s)
|
|
114
|
+
plen = pw.bytesize
|
|
115
|
+
ctx_a.update(digest_b * (plen / 16)) if plen >= 16
|
|
116
|
+
ctx_a.update(digest_b[0, plen % 16]) if (plen % 16) > 0
|
|
117
|
+
len = plen
|
|
118
|
+
while len > 0
|
|
119
|
+
ctx_a.update(len.odd? ? "\x00" : pw[0])
|
|
120
|
+
len >>= 1
|
|
121
|
+
end
|
|
122
|
+
digest_a = ctx_a.digest
|
|
123
|
+
|
|
124
|
+
# Main loop: 1000 rounds mixing the current digest with password and salt
|
|
125
|
+
c = digest_a
|
|
126
|
+
ROUNDS.times do |i|
|
|
127
|
+
ctx_c = OpenSSL::Digest::MD5.new
|
|
128
|
+
ctx_c.update(i.odd? ? pw : c)
|
|
129
|
+
ctx_c.update(s) unless (i % 3).zero?
|
|
130
|
+
ctx_c.update(pw) unless (i % 7).zero?
|
|
131
|
+
ctx_c.update(i.odd? ? c : pw)
|
|
132
|
+
c = ctx_c.digest
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
encode(c)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Encodes the 16-byte MD5 digest into 22 characters using the FreeBSD
|
|
139
|
+
# md5crypt byte ordering and the crypt64 alphabet.
|
|
140
|
+
def encode(digest)
|
|
141
|
+
b = digest.bytes
|
|
142
|
+
ENCODE_OFFSETS.map { |w2, w1, w0, n|
|
|
143
|
+
v = ((w2 ? b[w2] : 0) << 16) | ((w1 ? b[w1] : 0) << 8) | b[w0]
|
|
144
|
+
n.times.map { c = HASH_CHARS[v & 0x3f]; v >>= 6; c }.join
|
|
145
|
+
}.join
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def random_salt
|
|
149
|
+
Internal.random_bytes(6).bytes.map { HASH_CHARS[it & 63] }.join
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Passlib
|
|
4
|
+
# Abstract base class for all password hash implementations.
|
|
5
|
+
#
|
|
6
|
+
# Concrete subclasses (e.g. {BCrypt}, {PBKDF2}, {SHA2Crypt}) each implement a
|
|
7
|
+
# specific algorithm. The public interface is identical across all of them:
|
|
8
|
+
# use {.load} to parse a stored hash, {.create} to generate a new one, and
|
|
9
|
+
# {#verify} to verify a plaintext secret.
|
|
10
|
+
#
|
|
11
|
+
# Instances are immutable value objects. The underlying hash string is
|
|
12
|
+
# accessible via {#to_s}/{#to_str} and is always frozen.
|
|
13
|
+
#
|
|
14
|
+
# @abstract Subclass and implement the private +#create+ method (and optionally
|
|
15
|
+
# +#load+ and/or +#verify+) to add a new algorithm.
|
|
16
|
+
#
|
|
17
|
+
# @example Parsing an existing hash
|
|
18
|
+
# hash = Passlib::BCrypt.load("$2a$12$...")
|
|
19
|
+
# hash.verify("hunter2") # => true
|
|
20
|
+
#
|
|
21
|
+
# @example Creating a new hash
|
|
22
|
+
# hash = Passlib::BCrypt.create("hunter2", cost: 10)
|
|
23
|
+
# hash.to_s # => "$2a$10$..."
|
|
24
|
+
class Password
|
|
25
|
+
extend Internal::DSL
|
|
26
|
+
@configuration_class = Configuration::Passlib
|
|
27
|
+
|
|
28
|
+
# Parses a stored hash string and returns a {Password} instance.
|
|
29
|
+
#
|
|
30
|
+
# When called on {Password} directly (rather than a concrete subclass), the
|
|
31
|
+
# algorithm is auto-detected from the hash format. When called on a
|
|
32
|
+
# subclass, the string must be a valid hash for that algorithm.
|
|
33
|
+
#
|
|
34
|
+
# @param string [String] the stored password hash string
|
|
35
|
+
# @param config [Configuration, Hash, nil] optional configuration overrides
|
|
36
|
+
# @return [Password] a {Password} instance for the detected algorithm
|
|
37
|
+
# @raise [ArgumentError] if +string+ does not match any known format
|
|
38
|
+
# (only when called on {Password} directly)
|
|
39
|
+
# @raise [Passlib::UnknownHashFormat] if +string+ is not valid for the
|
|
40
|
+
# target algorithm (when called on a concrete subclass)
|
|
41
|
+
def self.load(string, config = nil, **)
|
|
42
|
+
return new(config(config, **), :load, string) if self != Password
|
|
43
|
+
case string
|
|
44
|
+
when /\A\$([\w-]+)/ then klass = Internal::Register::MFC_IDS[$1] || Internal::Register::MFC_IDS[$1[/\A\w+/]]
|
|
45
|
+
when /\A{([\w-]+)(?:,[^}]*)?}/ then klass = Internal::Register::LDAP_IDS[$1.upcase]
|
|
46
|
+
else
|
|
47
|
+
classes = Set.new
|
|
48
|
+
Internal::Register::PATTERNS.each do |pattern, candidate|
|
|
49
|
+
classes << candidate if pattern === string
|
|
50
|
+
end
|
|
51
|
+
klass = classes.first if classes.size == 1
|
|
52
|
+
end
|
|
53
|
+
return klass.load(string, config, **) if klass
|
|
54
|
+
raise ArgumentError, "unknown password hash format: #{string.inspect}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Creates a new password hash for the given secret.
|
|
58
|
+
#
|
|
59
|
+
# When called on a concrete subclass the algorithm is fixed. When called
|
|
60
|
+
# on {Password} directly, the algorithm is selected from the configuration:
|
|
61
|
+
# set +preferred_scheme+ (a single identifier) or +preferred_schemes+ (an
|
|
62
|
+
# ordered list) in the supplied {Configuration::Passlib} and the first
|
|
63
|
+
# available algorithm in the list is used. Raises {UnsupportedAlgorithm}
|
|
64
|
+
# when no usable scheme can be found.
|
|
65
|
+
#
|
|
66
|
+
# @param secret [String] the plaintext password to hash
|
|
67
|
+
# @param config [Configuration, Hash, nil] optional configuration overrides
|
|
68
|
+
# @param options [Hash] algorithm-specific keyword options (merged into +config+)
|
|
69
|
+
# @return [Password] a new {Password} instance wrapping the generated hash
|
|
70
|
+
# @raise [UnsupportedAlgorithm] if called on {Password} directly and no
|
|
71
|
+
# preferred scheme is configured or available
|
|
72
|
+
def self.create(secret, config = nil, **)
|
|
73
|
+
config = config(config, **)
|
|
74
|
+
return new(config, :create, secret) if self != Password
|
|
75
|
+
scheme = config.preferred_scheme
|
|
76
|
+
return ::Passlib[scheme].create(secret, config) if scheme
|
|
77
|
+
raise UnsupportedAlgorithm, "no preferred schemes configured" if config.preferred_schemes.empty?
|
|
78
|
+
raise UnsupportedAlgorithm, "no available preferred schemes: #{config.preferred_schemes.join(", ")}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Returns whether this algorithm's dependency gem is installed and available.
|
|
82
|
+
# @return [Boolean]
|
|
83
|
+
def self.available? = true
|
|
84
|
+
|
|
85
|
+
private_class_method :new, :method_added
|
|
86
|
+
|
|
87
|
+
# The configuration used when this instance was created or loaded.
|
|
88
|
+
# @return [Configuration]
|
|
89
|
+
attr_reader :config
|
|
90
|
+
alias configuration config
|
|
91
|
+
|
|
92
|
+
# The frozen hash string, e.g. +"$2a$12$..."+ or +"{SSHA}..."+ .
|
|
93
|
+
# @return [String]
|
|
94
|
+
attr_reader :string
|
|
95
|
+
alias to_str string
|
|
96
|
+
alias to_s string
|
|
97
|
+
|
|
98
|
+
# @api private
|
|
99
|
+
def initialize(config, method, ...)
|
|
100
|
+
super()
|
|
101
|
+
@config = config
|
|
102
|
+
result = send(method, ...)
|
|
103
|
+
@string ||= result.to_str
|
|
104
|
+
@string = @string.frozen? ? @string : @string.dup.freeze
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @!method create_comparable(secret)
|
|
108
|
+
# Re-hashes +secret+ using the same parameters (salt, rounds, etc.).
|
|
109
|
+
#
|
|
110
|
+
# Not all subclasses define it, use +respond_to?(:create_comparable)+ to check.
|
|
111
|
+
#
|
|
112
|
+
# @abstract
|
|
113
|
+
# @param secret [String] the plaintext password to re-hash
|
|
114
|
+
# @return [Password] a new instance hashed with the same parameters
|
|
115
|
+
|
|
116
|
+
# Verifies that +secret+ matches this password hash.
|
|
117
|
+
#
|
|
118
|
+
# Uses constant-time comparison via {Passlib.secure_compare} to prevent
|
|
119
|
+
# timing attacks.
|
|
120
|
+
#
|
|
121
|
+
# @param secret [String] the plaintext password to verify
|
|
122
|
+
# @return [Boolean] +true+ if the secret matches, +false+ otherwise
|
|
123
|
+
def verify(secret)
|
|
124
|
+
Passlib.secure_compare(self, create_comparable(secret))
|
|
125
|
+
rescue NameError => error
|
|
126
|
+
raise error unless error.name == :create_comparable
|
|
127
|
+
raise NotImplementedError, "subclass must implement #verify or #create_comparable"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @!parse alias match? verify
|
|
131
|
+
def match?(secret) = verify(secret)
|
|
132
|
+
alias valid? match?
|
|
133
|
+
alias valid_secret? match?
|
|
134
|
+
alias valid_password? match?
|
|
135
|
+
alias === match?
|
|
136
|
+
|
|
137
|
+
# Returns a human-readable representation including the class name and hash string.
|
|
138
|
+
# @return [String]
|
|
139
|
+
# @api private
|
|
140
|
+
def inspect = "#<#{self.class.name} #{string.inspect}>"
|
|
141
|
+
|
|
142
|
+
# Pretty-print support for +pp+.
|
|
143
|
+
# @param pp [PP] the pretty-printer instance
|
|
144
|
+
# @return [void]
|
|
145
|
+
# @api private
|
|
146
|
+
def pretty_print(pp)
|
|
147
|
+
pp.object_group(self) do
|
|
148
|
+
pp.breakable
|
|
149
|
+
pp.pp string
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Returns whether this hash should be re-hashed with the current configuration.
|
|
154
|
+
#
|
|
155
|
+
# Called by {Passlib::Configuration::Context#upgrade?} after confirming the
|
|
156
|
+
# hash already uses the preferred algorithm. Returns +true+ if any
|
|
157
|
+
# parameter (cost, rounds, etc.) does not exactly match the active
|
|
158
|
+
# configuration, including when stored costs are higher than configured
|
|
159
|
+
# (allowing a downgrade when parameters were set too high).
|
|
160
|
+
#
|
|
161
|
+
# @return [Boolean]
|
|
162
|
+
def upgrade? = raise NotImplementedError, "subclass must implement #upgrade?"
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
def load(string) = string
|
|
167
|
+
def create(string) = raise NotImplementedError, "subclass must override #create"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Passlib
|
|
4
|
+
# Handles PBKDF2 password hashing via OpenSSL, in Passlib's MCF format.
|
|
5
|
+
#
|
|
6
|
+
# Five digest variants are available: SHA-1, SHA-256, SHA-512, SHA3-256, and
|
|
7
|
+
# SHA3-512. The SHA3 variants are only usable when the OpenSSL build supports
|
|
8
|
+
# them; the default is SHA3-512 when available, falling back to SHA-512.
|
|
9
|
+
#
|
|
10
|
+
# Three hash formats are accepted on load, all normalized to MCF:
|
|
11
|
+
# - Passlib MCF: +$pbkdf2-sha256$rounds$salt$dk+
|
|
12
|
+
# - LDAP-style: +{PBKDF2-SHA256}rounds$salt$dk+
|
|
13
|
+
# - Cryptacular (+cta_pbkdf2_sha1+): +$p5k2$rounds_hex$salt_b64url$dk_b64url+
|
|
14
|
+
#
|
|
15
|
+
# New hashes are always produced in MCF.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# hash = Passlib::PBKDF2.create("hunter2", variant: "pbkdf2-sha256", rounds: 29_000)
|
|
19
|
+
# hash.verify("hunter2") # => true
|
|
20
|
+
# hash.to_s # => "$pbkdf2-sha256$29000$...$..."
|
|
21
|
+
#
|
|
22
|
+
# @example Loading an LDAP hash (normalized to MCF)
|
|
23
|
+
# hash = Passlib::PBKDF2.load("{PBKDF2-SHA256}29000$...$...")
|
|
24
|
+
# hash.to_s # => "$pbkdf2-sha256$29000$...$..."
|
|
25
|
+
#
|
|
26
|
+
# @example Loading a Cryptacular cta_pbkdf2_sha1 hash (normalized to MCF)
|
|
27
|
+
# hash = Passlib::PBKDF2.load("$p5k2$2710$...$...")
|
|
28
|
+
# hash.to_s # => "$pbkdf2$10000$...$..."
|
|
29
|
+
#
|
|
30
|
+
# @!method self.create(secret, **options)
|
|
31
|
+
# Creates a new PBKDF2 hash.
|
|
32
|
+
# @param secret [String] the plaintext password
|
|
33
|
+
# @option options [String, Symbol] :variant digest variant — one of +"pbkdf2"+ (SHA-1),
|
|
34
|
+
# +"pbkdf2-sha256"+, +"pbkdf2-sha512"+, +"pbkdf2-sha3-256"+, +"pbkdf2-sha3-512"+;
|
|
35
|
+
# case-insensitive, symbols accepted, underscores may be used instead of dashes
|
|
36
|
+
# (default: +"pbkdf2-sha3-512"+ if SHA3 is available, otherwise +"pbkdf2-sha512"+)
|
|
37
|
+
# @option options [Integer] :rounds iteration count (default: variant-specific)
|
|
38
|
+
# @option options [String] :salt raw binary salt (default: 16 random bytes)
|
|
39
|
+
# @option options [Integer] :key_len derived key length in bytes (default: variant-specific)
|
|
40
|
+
# @return [PBKDF2]
|
|
41
|
+
class PBKDF2 < Password
|
|
42
|
+
VARIANTS = {
|
|
43
|
+
"pbkdf2" => { digest: "SHA1", key_len: 20, default_rounds: 131_000 },
|
|
44
|
+
"pbkdf2-sha256" => { digest: "SHA256", key_len: 32, default_rounds: 29_000 },
|
|
45
|
+
"pbkdf2-sha512" => { digest: "SHA512", key_len: 64, default_rounds: 25_000 },
|
|
46
|
+
"pbkdf2-sha3-256" => { digest: "SHA3-256", key_len: 32, default_rounds: 29_000 },
|
|
47
|
+
"pbkdf2-sha3-512" => { digest: "SHA3-512", key_len: 64, default_rounds: 25_000 },
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
50
|
+
# Mapping from LDAP-style variant names to their MCF equivalents.
|
|
51
|
+
LDAP_VARIANTS = {
|
|
52
|
+
"PBKDF2" => "pbkdf2",
|
|
53
|
+
"PBKDF2-SHA256" => "pbkdf2-sha256",
|
|
54
|
+
"PBKDF2-SHA512" => "pbkdf2-sha512",
|
|
55
|
+
"PBKDF2-SHA3-256" => "pbkdf2-sha3-256",
|
|
56
|
+
"PBKDF2-SHA3-512" => "pbkdf2-sha3-512",
|
|
57
|
+
}.freeze
|
|
58
|
+
|
|
59
|
+
# AB64 alphabet: A-Za-z0-9 plus '.' (replaces '+') and '/'
|
|
60
|
+
MCF_PATTERN = /\A\$(pbkdf2(?:-sha3?-?\d+)?)\$(\d+)\$([A-Za-z0-9.\/]+)\$([A-Za-z0-9.\/]+)\z/
|
|
61
|
+
LDAP_PATTERN = /\A\{(PBKDF2[^}]*)\}(\d+)\$([A-Za-z0-9.\/]+)\$([A-Za-z0-9.\/]+)\z/i
|
|
62
|
+
# Cryptacular's PBKDF2-SHA1 format: $p5k2$<rounds_hex>$<salt_b64url>$<checksum_b64url>
|
|
63
|
+
CTA_PATTERN = %r{\A\$p5k2\$([0-9a-f]+)\$([A-Za-z0-9_=-]+)\$([A-Za-z0-9_=-]+)\z}
|
|
64
|
+
|
|
65
|
+
DEFAULT = OpenSSL::Digest.digests.any? { it.casecmp?("SHA3-512") } ? "pbkdf2-sha3-512" : "pbkdf2-sha512"
|
|
66
|
+
|
|
67
|
+
private_constant :VARIANTS, :LDAP_VARIANTS, :MCF_PATTERN, :LDAP_PATTERN, :CTA_PATTERN, :DEFAULT
|
|
68
|
+
|
|
69
|
+
register mcf: %w[pbkdf2 p5k2]
|
|
70
|
+
options :variant, :rounds, :salt, :key_len
|
|
71
|
+
|
|
72
|
+
# Register LDAP variant names for auto-detection via Passlib.load
|
|
73
|
+
LDAP_VARIANTS.each_key { Internal::Register::LDAP_IDS[_1] = self }
|
|
74
|
+
|
|
75
|
+
# @param secret [String] the plaintext password to re-hash
|
|
76
|
+
# @return [PBKDF2] a new instance hashed with the same variant, rounds, salt, and key length
|
|
77
|
+
def create_comparable(secret)
|
|
78
|
+
self.class.create(secret, variant: @variant, rounds: @rounds, salt: @salt, key_len: @key_len)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def upgrade?
|
|
82
|
+
v = VARIANTS[@variant] or return false
|
|
83
|
+
@rounds != (config.rounds || v[:default_rounds])
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def create(secret)
|
|
89
|
+
@variant ||= (config.variant || DEFAULT).to_s.downcase.tr("_", "-")
|
|
90
|
+
v = VARIANTS[@variant] or raise ArgumentError, "unknown pbkdf2 variant: #{@variant.inspect}"
|
|
91
|
+
@rounds ||= config.rounds || v[:default_rounds]
|
|
92
|
+
@key_len ||= config.key_len || v[:key_len]
|
|
93
|
+
@salt ||= config.salt || Internal.random_bytes(16)
|
|
94
|
+
dk = OpenSSL::PKCS5.pbkdf2_hmac(secret.to_s, @salt, @rounds, @key_len, v[:digest])
|
|
95
|
+
"$#{@variant}$#{@rounds}$#{Internal.encode_ab64(@salt)}$#{Internal.encode_ab64(dk)}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def load(string)
|
|
99
|
+
case string
|
|
100
|
+
when MCF_PATTERN
|
|
101
|
+
m = Regexp.last_match
|
|
102
|
+
@variant = m[1]
|
|
103
|
+
@rounds = m[2].to_i
|
|
104
|
+
@salt = Internal.decode_ab64(m[3])
|
|
105
|
+
@key_len = Internal.decode_ab64(m[4]).bytesize
|
|
106
|
+
string
|
|
107
|
+
when LDAP_PATTERN
|
|
108
|
+
m = Regexp.last_match
|
|
109
|
+
ldap_var = m[1].upcase
|
|
110
|
+
@variant = LDAP_VARIANTS[ldap_var] or raise UnknownHashFormat, "unknown LDAP PBKDF2 scheme: #{ldap_var.inspect}"
|
|
111
|
+
@rounds = m[2].to_i
|
|
112
|
+
@salt = Internal.decode_ab64(m[3])
|
|
113
|
+
@key_len = Internal.decode_ab64(m[4]).bytesize
|
|
114
|
+
"$#{@variant}$#{@rounds}$#{m[3]}$#{m[4]}"
|
|
115
|
+
when CTA_PATTERN
|
|
116
|
+
m = Regexp.last_match
|
|
117
|
+
@variant = "pbkdf2"
|
|
118
|
+
@rounds = m[1].to_i(16)
|
|
119
|
+
@salt = decode_url64(m[2])
|
|
120
|
+
@key_len = decode_url64(m[3]).bytesize
|
|
121
|
+
"$pbkdf2$#{@rounds}$#{Internal.encode_ab64(@salt)}$#{m[3].then { Internal.encode_ab64(decode_url64(_1)) }}"
|
|
122
|
+
else
|
|
123
|
+
raise UnknownHashFormat, "invalid pbkdf2 hash: #{string.inspect}"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def decode_url64(str)
|
|
128
|
+
s = str.tr("-_", "+/")
|
|
129
|
+
s += "=" * ((-s.length) % 4) unless s.end_with?("=")
|
|
130
|
+
s.unpack1("m")
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|