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