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,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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Passlib::Internal
4
+ module Register
5
+ IDENTIFIERS ||= {}
6
+ MFC_IDS ||= {}
7
+ LDAP_IDS ||= {}
8
+ PATTERNS ||= {}
9
+ end
10
+ 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