crypt_checkpass 1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,217 @@
1
+ #! /your/favourite/path/to/ruby
2
+ # -*- mode: ruby; coding: utf-8; indent-tabs-mode: nil; ruby-indent-level: 2 -*-
3
+ # -*- frozen_string_literal: true -*-
4
+ # -*- warn_indent: true -*-
5
+
6
+ # Copyright (c) 2018 Urabe, Shyouhei
7
+ #
8
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ # of this software and associated documentation files (the "Software"), to deal
10
+ # in the Software without restriction, including without limitation the rights
11
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ # copies of the Software, and to permit persons to whom the Software is
13
+ # furnished to do so, subject to the following conditions:
14
+ #
15
+ # The above copyright notice and this permission notice shall be
16
+ # included in all copies or substantial portions of the Software.
17
+ #
18
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ # SOFTWARE.
25
+
26
+ require 'consttime_memequal'
27
+
28
+ # BCrypt is a blowfish based password hash function. BSD devs and users tend
29
+ # to love this function. As of writing this is the only hash function that
30
+ # OpenBSD's crypt(3) understands. Also, because `ActiveModel::SecurePassword`
31
+ # is backended by this algorithm, Ruby on Rails users tends to use it.
32
+ #
33
+ # ### Newhash:
34
+ #
35
+ # In addition to the OpenBSD-ish usage described in {file:README.md}, you can
36
+ # also use `crypto_newhash` to create a new password hash using bcrypt:
37
+ #
38
+ # ```ruby
39
+ # crypt_newhash(password, id: 'bcrypt', rounds: 4, ident: '2b')
40
+ # ```
41
+ #
42
+ # where:
43
+ #
44
+ # - `password` is the raw binary password that you want to digest.
45
+ #
46
+ # - `id` is "bcrypt" when you want a bcrypt hash.
47
+ #
48
+ # - `rounds` is an integer ranging 4 to 31 inclusive, which is the number of
49
+ # iterations.
50
+ #
51
+ # - `ident` is the name of the variant. Variants of bcrypt are described
52
+ # below. Note however that what we don't support old variants that are
53
+ # known to be problematic. This parameter changes the name of the output
54
+ # but not the contents.
55
+ #
56
+ # The generated password hash has following format.
57
+ #
58
+ # ### Format:
59
+ #
60
+ # A bcrypt hashed string has following structure:
61
+ #
62
+ # ```ruby
63
+ # %r{
64
+ # (?<id> [2] [abxy]? ){0}
65
+ # (?<cost> [0-9]{2} ){0}
66
+ # (?<salt> [A-Za-z0-9./]{22} ){0}
67
+ # (?<csum> [A-Za-z0-9./]{31} ){0}
68
+ #
69
+ # \A [$] \g<id>
70
+ # [$] \g<cost>
71
+ # [$] \g<salt>
72
+ # \g<csum>
73
+ # \z
74
+ # }x
75
+ # ```
76
+ #
77
+ # - `id` is 2-something that denotes the variant of the hash. See below.
78
+ #
79
+ # - `cost` is a zero-padded decimal integer that specifies number of
80
+ # iterations in logs,
81
+ #
82
+ # - `salt` and `csum` are the salt and checksum strings. Both are encoded in
83
+ # base64-like strings that do not strictly follow RFC4648. There is no
84
+ # separating `$` sign is between them so you have to count the characters
85
+ # to tell which is which. Also, because they are base64, there are
86
+ # "unused" bits at the end of each.
87
+ #
88
+ # ### Variants:
89
+ #
90
+ # According to Wikipedia entry, there are 5 variants of bcrypt output:
91
+ #
92
+ # - Variant `$2$`: This was the initial version. It did not take Unicodes
93
+ # into account. Not currently active.
94
+ #
95
+ # - Variant `$2a$`: Unicode problem fixed, but suffered wraparound bug.
96
+ # OpenBSD people decided to abandon this to move to `$2b$`. Also suffered
97
+ # CVE-2011-2483. The people behind that CVE requested sysadmins to replace
98
+ # their `$2a$` with `$2x`, indicating the data is broken. Not currently
99
+ # active.
100
+ #
101
+ # - Variant `$2b$`: updated algorithm to fix wraparound bug. Now active.
102
+ #
103
+ # - Variant `$2x$`: see above. No new password hash shall generate this one.
104
+ #
105
+ # - Variant `$2y$`: updated algorithm to fix CVE-2011-2483. Now active.
106
+ #
107
+ # ### Fun facts:
108
+ #
109
+ # - It is by spec that the algorithm ignores password longer than 72 octets.
110
+ #
111
+ # - According to Python Passlib, variant `$2b$` and `$2y$` are "identical in
112
+ # all but name."
113
+ #
114
+ # - Rails (bcrypt-ruby) reportedly uses `$2a$` even today. However they seem
115
+ # fixed known flaws by themselves, without changing names. So their
116
+ # algorithm is arguably safe. Maybe this can be seen as a synonym of
117
+ # `$2b$` / `$2y`.
118
+ #
119
+ # @see https://en.wikipedia.org/wiki/Bcrypt
120
+ # @see https://www.usenix.org/legacy/event/usenix99/provos/provos_html/
121
+ # @example
122
+ # crypt_newhash 'password', id: 'bcrypt'
123
+ # # => "$2b$10$JlxIYWbT2EUDNvIwrIYcxuKf8pzf58IV4xVWk9yPy5J/ni0LCmz7G"
124
+ # @example
125
+ # crypt_checkpass? 'password', '$2b$10$JlxIYWbT2EUDNvIwrIYcxuKf8pzf58IV4xVWk9yPy5J/ni0LCmz7G'
126
+ # # => true
127
+ class CryptCheckpass::Bcrypt < CryptCheckpass
128
+
129
+ # (see CryptCheckpass.understand?)
130
+ def self.understand? str
131
+ return match? str, %r{
132
+ (?<id> [2] [abxy]? ){0}
133
+ (?<cost> [0-9]{2} ){0}
134
+ (?<remain> [A-Za-z0-9./]{53} ){0}
135
+ \A [$] \g<id>
136
+ [$] \g<cost>
137
+ [$] \g<remain>
138
+ \z
139
+ }x
140
+ end
141
+
142
+ # (see CryptCheckpass.checkpass?)
143
+ def self.checkpass? pass, hash
144
+ require 'bcrypt'
145
+
146
+ # bcrypt gem accepts `$2a$` and `$2x` only. We have to tweak.
147
+ expected = hash.sub %r/\A\$2[by]\$/, "$2a$"
148
+ obj = BCrypt::Password.new expected
149
+ actual = BCrypt::Engine.hash_secret pass, obj.salt
150
+ return consttime_memequal? expected, actual
151
+ end
152
+
153
+ # (see CryptCheckpass.provide?)
154
+ def self.provide? id
155
+ return id == 'bcrypt'
156
+ end
157
+
158
+ # (see CryptCheckpass.newhash)
159
+ #
160
+ # @param pass [String] raw binary password string.
161
+ # @param id [String] name of the algorithm (ignored)
162
+ # @param rounds [Integer] 4 to 31, inclusive.
163
+ # @param ident [String] "2b" or "2y" or something like that.
164
+ def self.newhash pass, id: 'bcrypt', rounds: nil, ident: '2b'
165
+ require 'bcrypt'
166
+ len = pass.bytesize
167
+ raise ArgumentError, <<-"end", len if len > 72
168
+ password is %d bytes, which is too long (up to 72)
169
+ end
170
+
171
+ rounds ||= BCrypt::Engine::DEFAULT_COST
172
+ case rounds when 4..31 then
173
+ return __generate pass, rounds, ident
174
+ else
175
+ raise ArgumentError, <<-"end", rounds
176
+ integer %d out of range of (4..31)
177
+ end
178
+ end
179
+ end
180
+
181
+ # This is to implement OpenBSD-style `crypt_newhash()` function.
182
+ #
183
+ # @param pass [String] bare, unhashed binary password.
184
+ # @param pref [String] algorithm preference specifier.
185
+ # @return [String] hashed digest string of password.
186
+ # @raise [NotImplementedError] pref not understandable.
187
+ # @see https://github.com/libressl-portable/openbsd/blob/master/src/lib/libc/crypt/cryptutil.c
188
+ def self.new_with_openbsd_pref pass, pref
189
+ require 'bcrypt'
190
+
191
+ func, rounds = pref.split ',', 2
192
+ unless match? func, /\A(bcrypt|blowfish)\z/ then
193
+ raise NotImplementedError, <<-"end".strip, func
194
+ hash algorithm %p not supported right now.
195
+ end
196
+ end
197
+
198
+ cost = nil
199
+ case rounds
200
+ when NilClass then cost = BCrypt::Engine::DEFAULT_COST
201
+ when "a" then cost = BCrypt::Engine::DEFAULT_COST
202
+ when /\A([12][0-9]|3[01]|[4-9])\z/ then cost = rounds.to_i
203
+ else
204
+ raise NotImplementedError, <<-"end".strip, rounds
205
+ cost function %p not supported right now.
206
+ end
207
+ end
208
+ return __generate pass, cost, '2b'
209
+ end
210
+
211
+ def self.__generate pass, cost, ident
212
+ salt = BCrypt::Engine.generate_salt(cost)
213
+ ret = BCrypt::Engine.hash_secret pass, salt
214
+ return ret.sub %r/\A\$2.?\$/, "$#{ident}$"
215
+ end
216
+ private_class_method :__generate
217
+ end
@@ -0,0 +1,177 @@
1
+ #! /your/favourite/path/to/ruby
2
+ # -*- mode: ruby; coding: utf-8; indent-tabs-mode: nil; ruby-indent-level: 2 -*-
3
+ # -*- frozen_string_literal: true -*-
4
+ # -*- warn_indent: true -*-
5
+
6
+ # Copyright (c) 2018 Urabe, Shyouhei
7
+ #
8
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ # of this software and associated documentation files (the "Software"), to deal
10
+ # in the Software without restriction, including without limitation the rights
11
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ # copies of the Software, and to permit persons to whom the Software is
13
+ # furnished to do so, subject to the following conditions:
14
+ #
15
+ # The above copyright notice and this permission notice shall be
16
+ # included in all copies or substantial portions of the Software.
17
+ #
18
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ # SOFTWARE.
25
+
26
+ require_relative 'phc_string_format'
27
+
28
+ # PBKDF2 is the most beloved algorithm by security professionals.
29
+ #
30
+ # ### Newhash:
31
+ #
32
+ # You can use `crypto_newhash` to create a new password hash using PBKDF2:
33
+ #
34
+ # ```ruby
35
+ # crypt_newhash(password, id: 'pbkdf2-sha1', rounds: 1024)
36
+ # ```
37
+ #
38
+ # where:
39
+ #
40
+ # - `password` is the raw binary password that you want to digest.
41
+ #
42
+ # - `id` is "pbkdf2-{digest}". You can specify sha1 / sha256 / sha512.
43
+ # Unlike plain SHA1, PBKDF2 + SHA1 combination still has no known weakness
44
+ # as of writing so specifying pbkdf-sha1 should just suffice normally.
45
+ #
46
+ # - `rounds` is for iteration rounds.
47
+ #
48
+ # The generated password hash has following format.
49
+ #
50
+ # ### Format:
51
+ #
52
+ # This algorithm does not have a standard hash format. Here we follow npm's
53
+ # @phc/pbkdf2.
54
+ #
55
+ # ```ruby
56
+ # %r{
57
+ # (?<id> pbkdf2-[\w\d]+ ){0}
58
+ # (?<i> i=[1-9][0-9]* ){0}
59
+ # (?<salt> [a-zA-Z0-9+/]* ){0}
60
+ # (?<csum> [a-zA-Z0-9+/]* ){0}
61
+ #
62
+ # \A [$] \g<id>
63
+ # [$] \g<i>
64
+ # [$] \g<salt>
65
+ # [$] \g<csum>
66
+ # \z
67
+ # }x
68
+ # ```
69
+ #
70
+ # - This is a strict PHC string format. See also
71
+ # {CryptCheckpass::PHCStringFormat}
72
+ #
73
+ # - The `id` can either be "pbkdf2-sha1", "pbkdf2-sha256", or "pbkdf2-sha512".
74
+ #
75
+ # - The only parameter `i` is the iteration (rounds) of the calculation.
76
+ #
77
+ # ### Other formats:
78
+ #
79
+ # Python Passlib generates something different, in the same `$pbkdf2-{digest}$`
80
+ # id. Passlib's and @phc/pbkdf2's are distinguishable because Passlib does not
81
+ # follow PHC String Format.
82
+ #
83
+ # @see https://en.wikipedia.org/wiki/PBKDF2
84
+ # @see https://tools.ietf.org/html/rfc2898
85
+ # @example
86
+ # crypt_newhash 'password', id: 'pbkdf2-sha1'
87
+ # # => "$pbkdf2-sha1$i=1024$a9b0ggwILmLgiAwV34bpzA$nJ+GYjlNDao8BJedGVc8UROXpcU"
88
+ # @example
89
+ # crypt_checkpass? 'password', '$pbkdf2-sha1$i=1024$a9b0ggwILmLgiAwV34bpzA$nJ+GYjlNDao8BJedGVc8UROXpcU'
90
+ # # => true
91
+ class CryptCheckpass::PBKDF2 < CryptCheckpass
92
+
93
+ # (see CryptCheckpass.understand?)
94
+ def self.understand? str
95
+ return match? str, %r{
96
+ (?<id> pbkdf2-sha(1|256|512) ){0}
97
+ (?<i> i=[1-9][0-9]* ){0}
98
+ (?<salt> [a-zA-Z0-9+/]* ){0}
99
+ (?<csum> [a-zA-Z0-9+/]* ){0}
100
+
101
+ \A [$] \g<id>
102
+ [$] \g<i>
103
+ [$] \g<salt>
104
+ [$] \g<csum>
105
+ \z
106
+ }x
107
+ end
108
+
109
+ # (see CryptCheckpass.checkpass?)
110
+ def self.checkpass? pass, hash
111
+ require 'consttime_memequal'
112
+
113
+ json = phcdecode hash
114
+ id = json[:id]
115
+ i = json[:params][:i]
116
+ salt = json[:salt]
117
+ expected = json[:csum]
118
+ dklen = expected.bytesize
119
+ actual = __derive_key id, i, salt, pass, dklen
120
+
121
+ return consttime_memequal? expected, actual
122
+ end
123
+
124
+ # (see CryptCheckpass.provide?)
125
+ def self.provide? id
126
+ case id when 'pbkdf2-sha1', 'pbkdf2-sha256', 'pbkdf2-sha512' then
127
+ return true
128
+ else
129
+ return false
130
+ end
131
+ end
132
+
133
+ # (see CryptCheckpass.newhash)
134
+ #
135
+ # @param pass [String] raw binary password string.
136
+ # @param id [String] name of the algorithm
137
+ # @param rounds [Integer] iteration rounds
138
+ def self.newhash pass, id: 'pbkdf2-sha1', rounds: 1024
139
+ salt = SecureRandom.random_bytes 16
140
+ csum = __derive_key id, rounds, salt, pass
141
+ return phcencode id, { i: rounds }, salt, csum
142
+ end
143
+ end
144
+
145
+ # helper routines
146
+ class << CryptCheckpass::PBKDF2
147
+ include CryptCheckpass::PHCStringFormat
148
+
149
+ private
150
+
151
+ if RUBY_VERSION >= '2.3.0'
152
+ def __load_openssl
153
+ require 'openssl'
154
+ end
155
+ else
156
+ def __load_openssl
157
+ Kernel.require 'openssl'
158
+ end
159
+ end
160
+
161
+ def __default_dklen_for digest
162
+ case digest
163
+ when 'pbkdf2-sha1' then return 20, 'sha1'
164
+ when 'pbkdf2-sha256' then return 32, 'sha256'
165
+ when 'pbkdf2-sha512' then return 64, 'sha512'
166
+ else raise "NOTREACHED: %s", id
167
+ end
168
+ end
169
+
170
+ def __derive_key id, iter, salt, pass, dklen = nil
171
+ __load_openssl
172
+
173
+ n, d = __default_dklen_for id
174
+ dklen ||= n
175
+ return OpenSSL::PKCS5.pbkdf2_hmac pass, salt, iter, dklen, d
176
+ end
177
+ end
@@ -0,0 +1,180 @@
1
+ #! /your/favourite/path/to/ruby
2
+ # -*- mode: ruby; coding: utf-8; indent-tabs-mode: nil; ruby-indent-level: 2 -*-
3
+ # -*- frozen_string_literal: true -*-
4
+ # -*- warn_indent: true -*-
5
+
6
+ # Copyright (c) 2018 Urabe, Shyouhei
7
+ #
8
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ # of this software and associated documentation files (the "Software"), to deal
10
+ # in the Software without restriction, including without limitation the rights
11
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ # copies of the Software, and to permit persons to whom the Software is
13
+ # furnished to do so, subject to the following conditions:
14
+ #
15
+ # The above copyright notice and this permission notice shall be
16
+ # included in all copies or substantial portions of the Software.
17
+ #
18
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ # SOFTWARE.
25
+
26
+ # Helper module to handle PHC String Format-compatible strings
27
+ #
28
+ # @note Argon2, which is the winner of PHC, ignores this format and go wild.
29
+ # It is highly skeptical that any other hash authors would switch to
30
+ # PHC's recommendation.
31
+ #
32
+ # ### Format
33
+ #
34
+ # This is how we understand the PHC String Format:
35
+ #
36
+ # ```ruby
37
+ # %r{
38
+ # (?<name> [a-z0-9-]{,32} ){0}
39
+ # (?<decimal> 0|-?[1-9][0-9]* ){0}
40
+ # (?<b64> [a-zA-Z0-9/+.-]* ){0}
41
+ #
42
+ # (?<id> \g<name> ){0}
43
+ # (?<param> \g<name> ){0}
44
+ # (?<value> \g<decimal> | \g<b64> ){0}
45
+ # (?<salt> \g<b64> ){0}
46
+ # (?<csum> \g<b64> ){0}
47
+ # (?<pair> \g<param> = \g<value> ){0}
48
+ # (?<pairs> \g<pair> (?:[,] \g<pair> )* ){0}
49
+ #
50
+ # \A [$] \g<id>
51
+ # [$] \g<pairs>
52
+ # [$] \g<salt>
53
+ # [$] \g<csum>
54
+ # \z
55
+ # }x
56
+ # ```
57
+ #
58
+ # - `id` is the name of the algorithm.
59
+ #
60
+ # - `pairs` is a set of key-value pair, that are parameters to the
61
+ # algorithm. Keys should be human-readable, while values need not be.
62
+ #
63
+ # - `salt` and `csum` are the salt and checksum strings. Both are encoded in
64
+ # what the spec says the "B64" encoding, which is a very slightly modified
65
+ # version of RFC4648 (no trailing ==... padding). They both can be
66
+ # arbitrary length.
67
+ #
68
+ # @see https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md
69
+ module CryptCheckpass::PHCStringFormat
70
+ private
71
+
72
+ # B64 encoder
73
+ # @param str [String] arbitrary binary string.
74
+ # @return [String] str, encoded in B64.
75
+ def b64encode str
76
+ var = [ str ].pack 'm0'
77
+ var.delete! '='
78
+ return var
79
+ end
80
+
81
+ if RUBY_VERSION >= '2.4.0' then
82
+ def malloc n
83
+ String.new capacity: n
84
+ end
85
+ else
86
+ def malloc n
87
+ String.new
88
+ end
89
+ end
90
+
91
+ # B64 decoder
92
+ # @param str [String] str, encoded in B64.
93
+ # @return [String] decoded binary string
94
+ # @raise [ArgumentError] str not in B64 encoding.
95
+ def b64decode str
96
+ return nil if str.nil?
97
+ n, m = str.length.divmod 4
98
+ raise ArgumentError, <<-"end".strip, str.length if m == 1
99
+ malformed string of %d octets passed.
100
+ end
101
+ buf = malloc(n * 4)
102
+ buf << str
103
+ buf << ('=' * ((4 - m) % 4))
104
+ return buf.unpack('m0').first
105
+ end
106
+
107
+ # Form a PHC String Formatted string.
108
+ # @return [String] a PHC String Formatted string.
109
+ # @param id [String] name of hash algorithm.
110
+ # @param params [<<String, Integer>>] hash algorithm parameters.
111
+ # @param salt [String] raw binary salt.
112
+ # @param csum [String] raw binary checksum
113
+ # @note The spec says:
114
+ #
115
+ # > The function MUST specify the order in which parameters may
116
+ # > appear. Producers MUST NOT allow parameters to appear in any
117
+ # > other order.
118
+ #
119
+ # Ensuring that property is up to the caller of this method.
120
+ def phcencode id, params, salt, csum
121
+ return [
122
+ '',
123
+ id,
124
+ params.map {|a| a.join '=' }.join(','),
125
+ b64encode(salt),
126
+ b64encode(csum)
127
+ ].join('$')
128
+ end
129
+
130
+ # Decompose a PHC String Formatted string.
131
+ # @param str [String] str, encoded in PHC's format.
132
+ # @return [Hash] decoded JSON.
133
+ def phcdecode str
134
+ grammar = %r{
135
+ (?<name> [a-z0-9-]{,32} ){0}
136
+ (?<decimal> 0|-?[1-9][0-9]* ){0}
137
+ (?<b64> [a-zA-Z0-9/+.-]* ){0}
138
+
139
+ (?<id> \g<name> ){0}
140
+ (?<param> \g<name> ){0}
141
+ (?<value> \g<decimal> | \g<b64> ){0}
142
+ (?<salt> \g<b64> ){0}
143
+ (?<csum> \g<b64> ){0}
144
+ (?<pair> \g<param> = \g<value> ){0}
145
+ (?<pairs> \g<pair> (?:[,] \g<pair> )* ){0}
146
+ }x
147
+
148
+ md = %r{
149
+ #{grammar}
150
+
151
+ \A [$] \g<id>
152
+ [$] \g<pairs>
153
+ [$] \g<salt>
154
+ [$] \g<csum>
155
+ \z
156
+ }xo.match str
157
+ raise "not in PHC String Format: %p", str unless md
158
+
159
+ return {
160
+ id: md['id'],
161
+ params: md['pairs'] \
162
+ . split(',', -1) \
163
+ . map {|i|
164
+ m = /#{grammar}\A\g<pair>\z/o.match i
165
+ next [
166
+ m['param'],
167
+ m['decimal'] ? m['decimal'].to_i : m['b64']
168
+ ]
169
+ }.each_with_object({}) {|(k, v), h|
170
+ h.update(k.to_s.to_sym => v) { |_, w, _|
171
+ raise <<-"end".strip, md['params'], k, v, w
172
+ %p includes conflicting values for %p: %p versus %p
173
+ end
174
+ }
175
+ },
176
+ salt: b64decode(md['salt']),
177
+ csum: b64decode(md['csum']),
178
+ }
179
+ end
180
+ end