1
1
# frozen_string_literal: true
2
2
3
- # Net::IMAP authenticator for the "` DIGEST-MD5`" SASL mechanism type, specified
3
+ # Net::IMAP authenticator for the + DIGEST-MD5+ SASL mechanism type, specified
4
4
# in RFC-2831[https://tools.ietf.org/html/rfc2831]. See Net::IMAP#authenticate.
5
5
#
6
6
# == Deprecated
9
9
# RFC-6331[https://tools.ietf.org/html/rfc6331] and should not be relied on for
10
10
# security. It is included for compatibility with existing servers.
11
11
class Net ::IMAP ::SASL ::DigestMD5Authenticator
12
+ DataFormatError = Net ::IMAP ::DataFormatError
13
+ ResponseParseError = Net ::IMAP ::ResponseParseError
14
+ private_constant :DataFormatError , :ResponseParseError
15
+
12
16
STAGE_ONE = :stage_one
13
17
STAGE_TWO = :stage_two
14
18
STAGE_DONE = :stage_done
@@ -22,6 +26,7 @@ class Net::IMAP::SASL::DigestMD5Authenticator
22
26
# RFC-4616[https://tools.ietf.org/html/rfc4616] and many later RFCs abbreviate
23
27
# that to +authcid+. So +authcid+ is available as an alias for #username.
24
28
attr_reader :username
29
+ alias authcid username
25
30
26
31
# A password or passphrase that matches the #username.
27
32
#
@@ -41,6 +46,60 @@ class Net::IMAP::SASL::DigestMD5Authenticator
41
46
#
42
47
attr_reader :authzid
43
48
49
+ # A namespace or collection of identities which contains +username+.
50
+ #
51
+ # Used by DIGEST-MD5, GSS-API, and NTLM. This is often a domain name that
52
+ # contains the name of the host performing the authentication.
53
+ #
54
+ # <em>Defaults to the last realm in the server-provided list of
55
+ # realms.</em>
56
+ attr_reader :realm
57
+
58
+ # Fully qualified canonical DNS host name for the requested service.
59
+ #
60
+ # <em>Defaults to #realm.</em>
61
+ attr_reader :host
62
+
63
+ # The service protocol, a
64
+ # {registered GSSAPI service name}[https://www.iana.org/assignments/gssapi-service-names/gssapi-service-names.xhtml],
65
+ # e.g. "imap", "ldap", or "xmpp".
66
+ #
67
+ # For Net::IMAP, the default is "imap" and should not be overridden. This
68
+ # must be set appropriately to use authenticators in other protocols.
69
+ #
70
+ # If an IANA-registered name isn't available, GSS-API
71
+ # (RFC-2743[https://tools.ietf.org/html/rfc2743]) allows the generic name
72
+ # "host".
73
+ attr_reader :service
74
+
75
+ # The generic server name when the server is replicated.
76
+ #
77
+ # Not used by other \SASL mechanisms. +service_name+ will be ignored when it
78
+ # is +nil+ or identical to +host+.
79
+ #
80
+ # From RFC-2831[https://tools.ietf.org/html/rfc2831]:
81
+ # >>>
82
+ # The service is considered to be replicated if the client's
83
+ # service-location process involves resolution using standard DNS lookup
84
+ # operations, and if these operations involve DNS records (such as SRV, or
85
+ # MX) which resolve one DNS name into a set of other DNS names. In this
86
+ # case, the initial name used by the client is the "serv-name", and the
87
+ # final name is the "host" component.
88
+ attr_reader :service_name
89
+
90
+ # Parameters sent by the server are stored in this hash.
91
+ attr_reader :sparams
92
+
93
+ # The charset sent by the server. "UTF-8" (case insensitive) is the only
94
+ # allowed value. +nil+ should be interpreted as ISO 8859-1.
95
+ attr_reader :charset
96
+
97
+ # nonce sent by the server
98
+ attr_reader :nonce
99
+
100
+ # qop-options sent by the server
101
+ attr_reader :qop
102
+
44
103
# :call-seq:
45
104
# new(username, password, authzid = nil, **options) -> authenticator
46
105
# new(username:, password:, authzid: nil, **options) -> authenticator
@@ -54,88 +113,77 @@ class Net::IMAP::SASL::DigestMD5Authenticator
54
113
# * #username — Identity whose #password is used.
55
114
# * #password — A password or passphrase associated with this #username.
56
115
# * #authzid ― Alternate identity to act as or on behalf of. Optional.
116
+ # * #realm — A namespace for the #username, e.g. a domain. <em>Defaults to the
117
+ # last realm in the server-provided .</em>
118
+ # * #host — FQDN for requested service. <em>Defaults to</em> #realm.
119
+ # * #service_name — The generic host name when the server is replicated.
120
+ # * #service — the registered service protocol. e.g. "imap", "smtp", "ldap",
121
+ # "xmpp". <em>For Net::IMAP, this defaults to "imap".</em>
57
122
# * +warn_deprecation+ — Set to +false+ to silence the warning.
58
123
#
59
124
# See the documentation for each attribute for more details.
60
- def initialize ( user = nil , pass = nil , authz = nil ,
125
+ def initialize ( username_arg = nil , password_arg = nil , authzid_arg = nil ,
61
126
username : nil , password : nil , authzid : nil ,
62
- warn_deprecation : true , ** )
63
- username ||= user or raise ArgumentError , "missing username"
64
- password ||= pass or raise ArgumentError , "missing password"
65
- authzid ||= authz
127
+ authcid : nil , # alias for username
128
+ realm : nil , service : "imap" , host : nil , service_name : nil ,
129
+ warn_deprecation : true ,
130
+ ** )
66
131
if warn_deprecation
67
- warn "WARNING: DIGEST-MD5 SASL mechanism was deprecated by RFC6331."
68
- # TODO: recommend SCRAM instead.
132
+ warn "WARNING: DIGEST-MD5 SASL mechanism was deprecated by RFC-6331."
69
133
end
134
+
70
135
require "digest/md5"
136
+ require "securerandom"
71
137
require "strscan"
72
- @username , @password , @authzid = username , password , authzid
138
+
139
+ @username = username || username_arg || authcid
140
+ @password = password || password_arg
141
+ @authzid = authzid || authzid_arg
142
+ @realm = realm
143
+ @host = host
144
+ @service = service
145
+ @service_name = service_name
146
+
147
+ @username or raise ArgumentError , "missing username"
148
+ @password or raise ArgumentError , "missing password"
149
+ [ username , username_arg , authcid ] . compact . count == 1 or
150
+ raise ArgumentError , "conflicting values for username"
151
+ [ password , password_arg ] . compact . count == 1 or
152
+ raise ArgumentError , "conflicting values for password"
153
+ [ authzid , authzid_arg ] . compact . count <= 1 or
154
+ raise ArgumentError , "conflicting values for authzid"
155
+
73
156
@nc , @stage = { } , STAGE_ONE
74
157
end
75
158
159
+ # From RFC-2831[https://tools.ietf.org/html/rfc2831]:
160
+ # >>>
161
+ # Indicates the principal name of the service with which the client wishes
162
+ # to connect, formed from the serv-type, host, and serv-name. For
163
+ # example, the FTP service on "ftp.example.com" would have a "digest-uri"
164
+ # value of "ftp/ftp.example.com"; the SMTP server from the example above
165
+ # would have a "digest-uri" value of "smtp/mail3.example.com/example.com".
166
+ def digest_uri
167
+ if service_name && service_name != host
168
+ "#{ service } /#{ host } /#{ service_name } "
169
+ else
170
+ "#{ service } /#{ host } "
171
+ end
172
+ end
173
+
76
174
def initial_response? ; false end
77
175
78
176
# Responds to server challenge in two stages.
79
177
def process ( challenge )
80
178
case @stage
81
179
when STAGE_ONE
82
180
@stage = STAGE_TWO
83
- sparams = { }
84
- c = StringScanner . new ( challenge )
85
- while c . scan ( /(?:\s *,)?\s *(\w +)=("(?:[^\\ "]|\\ .)*"|[^,]+)\s */ )
86
- k , v = c [ 1 ] , c [ 2 ]
87
- if v =~ /^"(.*)"$/
88
- v = $1
89
- if v =~ /,/
90
- v = v . split ( ',' )
91
- end
92
- end
93
- sparams [ k ] = v
94
- end
95
-
96
- raise Net ::IMAP ::DataFormatError , "Bad Challenge: '#{ challenge } '" unless c . eos? and sparams [ 'qop' ]
97
- raise Net ::IMAP ::Error , "Server does not support auth (qop = #{ sparams [ 'qop' ] . join ( ',' ) } )" unless sparams [ 'qop' ] . include? ( "auth" )
98
-
99
- response = {
100
- :nonce => sparams [ 'nonce' ] ,
101
- :username => @username ,
102
- :realm => sparams [ 'realm' ] ,
103
- :cnonce => Digest ::MD5 . hexdigest ( "%.15f:%.15f:%d" % [ Time . now . to_f , rand , Process . pid . to_s ] ) ,
104
- :'digest-uri' => 'imap/' + sparams [ 'realm' ] ,
105
- :qop => 'auth' ,
106
- :maxbuf => 65535 ,
107
- :nc => "%08d" % nc ( sparams [ 'nonce' ] ) ,
108
- :charset => sparams [ 'charset' ] ,
109
- }
110
-
111
- response [ :authzid ] = @authzid unless @authzid . nil?
112
-
113
- # now, the real thing
114
- a0 = Digest ::MD5 . digest ( [ response . values_at ( :username , :realm ) , @password ] . join ( ':' ) )
115
-
116
- a1 = [ a0 , response . values_at ( :nonce , :cnonce ) ] . join ( ':' )
117
- a1 << ':' + response [ :authzid ] unless response [ :authzid ] . nil?
118
-
119
- a2 = "AUTHENTICATE:" + response [ :'digest-uri' ]
120
- a2 << ":00000000000000000000000000000000" if response [ :qop ] and response [ :qop ] =~ /^auth-(?:conf|int)$/
121
-
122
- response [ :response ] = Digest ::MD5 . hexdigest (
123
- [
124
- Digest ::MD5 . hexdigest ( a1 ) ,
125
- response . values_at ( :nonce , :nc , :cnonce , :qop ) ,
126
- Digest ::MD5 . hexdigest ( a2 )
127
- ] . join ( ':' )
128
- )
129
-
130
- return response . keys . map { |key | qdval ( key . to_s , response [ key ] ) } . join ( ',' )
181
+ process_stage_one ( challenge )
182
+ stage_one_response
131
183
when STAGE_TWO
132
184
@stage = STAGE_DONE
133
- # if at the second stage, return an empty string
134
- if challenge =~ /rspauth=/
135
- return ''
136
- else
137
- raise ResponseParseError , challenge
138
- end
185
+ process_stage_two ( challenge )
186
+ "" # if at the second stage, return an empty string
139
187
else
140
188
raise ResponseParseError , challenge
141
189
end
@@ -145,23 +193,158 @@ def done?; @stage == STAGE_DONE end
145
193
146
194
private
147
195
196
+ def process_stage_one ( challenge )
197
+ @sparams = parse_challenge ( challenge )
198
+ @qop = sparams . key? ( "qop" ) ? [ "auth" ] : sparams [ "qop" ] . flatten
199
+
200
+ guard_stage_one ( challenge )
201
+
202
+ @nonce = sparams [ "nonce" ] . first
203
+ @charset = sparams [ "charset" ] . first
204
+
205
+ @realm ||= sparams [ "realm" ] . last
206
+ @host ||= realm
207
+ end
208
+
209
+ def guard_stage_one ( challenge )
210
+ if !qop . include? ( "auth" )
211
+ raise DataFormatError , "Server does not support auth (qop = %p)" % [
212
+ sparams [ "qop" ]
213
+ ]
214
+ elsif ( emptykey = REQUIRED . find { sparams [ _1 ] . empty? } )
215
+ raise DataFormatError , "Server didn't send %p (%p)" % [ emptykey , challenge ]
216
+ elsif ( multikey = NO_MULTIPLES . find { sparams [ _1 ] . length > 1 } )
217
+ raise DataFormatError , "Server sent multiple %p (%p)" % [ multikey , challenge ]
218
+ end
219
+ end
220
+
221
+ def stage_one_response
222
+ response = {
223
+ nonce : nonce ,
224
+ username : username ,
225
+ realm : realm ,
226
+ cnonce : SecureRandom . base64 ( 32 ) ,
227
+ "digest-uri" : digest_uri ,
228
+ qop : "auth" ,
229
+ maxbuf : 65535 ,
230
+ nc : "%08d" % nc ( nonce ) ,
231
+ charset : charset ,
232
+ }
233
+
234
+ response [ :authzid ] = authzid unless authzid . nil?
235
+ response [ :response ] = compute_digest ( response )
236
+
237
+ format_response ( response )
238
+ end
239
+
240
+ def process_stage_two ( challenge )
241
+ raise ResponseParseError , challenge unless challenge =~ /rspauth=/
242
+ end
243
+
148
244
def nc ( nonce )
149
- if @nc . has_key? nonce
150
- @nc [ nonce ] = @nc [ nonce ] + 1
151
- else
152
- @nc [ nonce ] = 1
245
+ @nc [ nonce ] = @nc . key? ( nonce ) ? @nc [ nonce ] + 1 : 1
246
+ @nc [ nonce ]
247
+ end
248
+
249
+ def compute_digest ( response )
250
+ a1 = compute_a1 ( response )
251
+ a2 = compute_a2 ( response )
252
+ Digest ::MD5 . hexdigest (
253
+ [
254
+ Digest ::MD5 . hexdigest ( a1 ) ,
255
+ response . values_at ( :nonce , :nc , :cnonce , :qop ) ,
256
+ Digest ::MD5 . hexdigest ( a2 )
257
+ ] . join ( ":" )
258
+ )
259
+ end
260
+
261
+ def compute_a0 ( response )
262
+ Digest ::MD5 . digest (
263
+ [ response . values_at ( :username , :realm ) , password ] . join ( ":" )
264
+ )
265
+ end
266
+
267
+ def compute_a1 ( response )
268
+ a0 = compute_a0 ( response )
269
+ a1 = [ a0 , response . values_at ( :nonce , :cnonce ) ] . join ( ":" )
270
+ a1 << ":#{ response [ :authzid ] } " unless response [ :authzid ] . nil?
271
+ a1
272
+ end
273
+
274
+ def compute_a2 ( response )
275
+ a2 = "AUTHENTICATE:#{ response [ :"digest-uri" ] } "
276
+ if response [ :qop ] and response [ :qop ] =~ /^auth-(?:conf|int)$/
277
+ a2 << ":00000000000000000000000000000000"
278
+ end
279
+ a2
280
+ end
281
+
282
+ # Directives which must not have multiples. The RFC states:
283
+ # >>>
284
+ # This directive may appear at most once; if multiple instances are present,
285
+ # the client should abort the authentication exchange.
286
+ NO_MULTIPLES = %w[ nonce stale maxbuf charset algorithm ] . freeze
287
+
288
+ # Required directives which must occur exactly once. The RFC states: >>>
289
+ # This directive is required and MUST appear exactly once; if not present,
290
+ # or if multiple instances are present, the client should abort the
291
+ # authentication exchange.
292
+ REQUIRED = %w[ nonce algorithm ] . freeze
293
+
294
+ # Directives which are composed of one or more comma delimited tokens
295
+ QUOTED_LISTABLE = %w[ qop cipher ] . freeze
296
+
297
+ private_constant :NO_MULTIPLES , :REQUIRED , :QUOTED_LISTABLE
298
+
299
+ LWS = /[\r \n \t ]*/n # less strict than RFC, more strict than '\s'
300
+ TOKEN = /[^\x00 -\x20 \x7f ()<>@,;:\\ "\/ \[ \] ?={}]+/n
301
+ QUOTED_STR = /"(?: [\t \x20 -\x7e &&[^"]] | \\ [\x00 -\x7f ] )*"/nx
302
+ LIST_DELIM = /(?:#{ LWS } , )+ #{ LWS } /nx
303
+ AUTH_PARAM = /
304
+ (#{ TOKEN } ) #{ LWS } = #{ LWS } (#{ QUOTED_STR } | #{ TOKEN } ) #{ LIST_DELIM } ?
305
+ /nx
306
+
307
+ private_constant :LWS , :TOKEN , :QUOTED_STR , :LIST_DELIM , :AUTH_PARAM
308
+
309
+ def parse_challenge ( challenge )
310
+ sparams = Hash . new { |h , k | h [ k ] = [ ] }
311
+ c = StringScanner . new ( challenge )
312
+ c . skip LIST_DELIM
313
+ while c . scan AUTH_PARAM
314
+ k , v = c [ 1 ] , c [ 2 ]
315
+ k = k . downcase
316
+ if v =~ /\A "(.*)"\z /mn
317
+ v = $1. gsub ( /\\ (.)/mn , '\1' )
318
+ v = split_quoted_list ( v , challenge ) if QUOTED_LISTABLE . include? k
319
+ end
320
+ sparams [ k ] << v
321
+ end
322
+ c . eos? or raise DataFormatError , "Bad Challenge: %p" % [ challenge ]
323
+ sparams . any? or raise DataFormatError , "Bad Challenge: %p" % [ challenge ]
324
+ sparams
325
+ end
326
+
327
+ def split_quoted_list ( value , challenge )
328
+ value . split ( LIST_DELIM ) . reject ( &:empty? ) . tap do
329
+ _1 . any? or raise DataFormatError , "Bad Challenge: %p" % [ challenge ]
153
330
end
154
- return @nc [ nonce ]
331
+ end
332
+
333
+ def format_response ( response )
334
+ response
335
+ . keys
336
+ . map { |key | qdval ( key . to_s , response [ key ] ) }
337
+ . join ( "," )
155
338
end
156
339
157
340
# some responses need quoting
158
- def qdval ( k , v )
159
- return if k . nil? or v . nil?
160
- if %w" username authzid realm nonce cnonce digest-uri qop " . include? k
161
- v = v . gsub ( /([\\ "])/ , "\\ \1 " )
162
- return '%s="%s"' % [ k , v ]
341
+ def qdval ( key , val )
342
+ return if key . nil? or val . nil?
343
+ if %w[ username authzid realm nonce cnonce digest-uri qop ] . include? key
344
+ val = val . gsub ( /([\\ "])/n , "\\ \1 " )
345
+ '%s="%s"' % [ key , val ]
163
346
else
164
- return ' %s=%s' % [ k , v ]
347
+ " %s=%s" % [ key , val ]
165
348
end
166
349
end
167
350
0 commit comments