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