11# frozen_string_literal: true
22
3- # Net::IMAP authenticator for the "` DIGEST-MD5`" SASL mechanism type, specified
3+ # Net::IMAP authenticator for the + DIGEST-MD5+ SASL mechanism type, specified
44# in RFC-2831[https://tools.ietf.org/html/rfc2831]. See Net::IMAP#authenticate.
55#
66# == Deprecated
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+ private_constant :DataFormatError , :ResponseParseError
15+
1216 STAGE_ONE = :stage_one
1317 STAGE_TWO = :stage_two
1418 STAGE_DONE = :stage_done
@@ -22,6 +26,7 @@ class Net::IMAP::SASL::DigestMD5Authenticator
2226 # RFC-4616[https://tools.ietf.org/html/rfc4616] and many later RFCs abbreviate
2327 # that to +authcid+. So +authcid+ is available as an alias for #username.
2428 attr_reader :username
29+ alias authcid username
2530
2631 # A password or passphrase that matches the #username.
2732 #
@@ -41,6 +46,60 @@ class Net::IMAP::SASL::DigestMD5Authenticator
4146 #
4247 attr_reader :authzid
4348
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+
44103 # :call-seq:
45104 # new(username, password, authzid = nil, **options) -> authenticator
46105 # new(username:, password:, authzid: nil, **options) -> authenticator
@@ -54,86 +113,75 @@ class Net::IMAP::SASL::DigestMD5Authenticator
54113 # * #username — Identity whose #password is used.
55114 # * #password — A password or passphrase associated with this #username.
56115 # * #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>
57122 # * +warn_deprecation+ — Set to +false+ to silence the warning.
58123 #
59124 # 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 ,
61126 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+ ** )
66131 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."
69133 end
134+
70135 require "digest/md5"
136+ require "securerandom"
71137 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+
73156 @nc , @stage = { } , STAGE_ONE
74157 end
75158
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+
76174 # Responds to server challenge in two stages.
77175 def process ( challenge )
78176 case @stage
79177 when STAGE_ONE
80178 @stage = STAGE_TWO
81- sparams = { }
82- c = StringScanner . new ( challenge )
83- while c . scan ( /(?:\s *,)?\s *(\w +)=("(?:[^\\ "]|\\ .)*"|[^,]+)\s */ )
84- k , v = c [ 1 ] , c [ 2 ]
85- if v =~ /^"(.*)"$/
86- v = $1
87- if v =~ /,/
88- v = v . split ( ',' )
89- end
90- end
91- sparams [ k ] = v
92- end
93-
94- raise Net ::IMAP ::DataFormatError , "Bad Challenge: '#{ challenge } '" unless c . eos? and sparams [ 'qop' ]
95- raise Net ::IMAP ::Error , "Server does not support auth (qop = #{ sparams [ 'qop' ] . join ( ',' ) } )" unless sparams [ 'qop' ] . include? ( "auth" )
96-
97- response = {
98- :nonce => sparams [ 'nonce' ] ,
99- :username => @username ,
100- :realm => sparams [ 'realm' ] ,
101- :cnonce => Digest ::MD5 . hexdigest ( "%.15f:%.15f:%d" % [ Time . now . to_f , rand , Process . pid . to_s ] ) ,
102- :'digest-uri' => 'imap/' + sparams [ 'realm' ] ,
103- :qop => 'auth' ,
104- :maxbuf => 65535 ,
105- :nc => "%08d" % nc ( sparams [ 'nonce' ] ) ,
106- :charset => sparams [ 'charset' ] ,
107- }
108-
109- response [ :authzid ] = @authzid unless @authzid . nil?
110-
111- # now, the real thing
112- a0 = Digest ::MD5 . digest ( [ response . values_at ( :username , :realm ) , @password ] . join ( ':' ) )
113-
114- a1 = [ a0 , response . values_at ( :nonce , :cnonce ) ] . join ( ':' )
115- a1 << ':' + response [ :authzid ] unless response [ :authzid ] . nil?
116-
117- a2 = "AUTHENTICATE:" + response [ :'digest-uri' ]
118- a2 << ":00000000000000000000000000000000" if response [ :qop ] and response [ :qop ] =~ /^auth-(?:conf|int)$/
119-
120- response [ :response ] = Digest ::MD5 . hexdigest (
121- [
122- Digest ::MD5 . hexdigest ( a1 ) ,
123- response . values_at ( :nonce , :nc , :cnonce , :qop ) ,
124- Digest ::MD5 . hexdigest ( a2 )
125- ] . join ( ':' )
126- )
127-
128- return response . keys . map { |key | qdval ( key . to_s , response [ key ] ) } . join ( ',' )
179+ process_stage_one ( challenge )
180+ stage_one_response
129181 when STAGE_TWO
130182 @stage = STAGE_DONE
131- # if at the second stage, return an empty string
132- if challenge =~ /rspauth=/
133- return ''
134- else
135- raise ResponseParseError , challenge
136- end
183+ process_stage_two ( challenge )
184+ "" # if at the second stage, return an empty string
137185 else
138186 raise ResponseParseError , challenge
139187 end
@@ -143,23 +191,158 @@ def done?; @stage == STAGE_DONE end
143191
144192 private
145193
194+ def process_stage_one ( challenge )
195+ @sparams = parse_challenge ( challenge )
196+ @qop = sparams . key? ( "qop" ) ? [ "auth" ] : sparams [ "qop" ] . flatten
197+
198+ guard_stage_one ( challenge )
199+
200+ @nonce = sparams [ "nonce" ] . first
201+ @charset = sparams [ "charset" ] . first
202+
203+ @realm ||= sparams [ "realm" ] . last
204+ @host ||= realm
205+ end
206+
207+ def guard_stage_one ( challenge )
208+ if !qop . include? ( "auth" )
209+ raise DataFormatError , "Server does not support auth (qop = %p)" % [
210+ sparams [ "qop" ]
211+ ]
212+ elsif ( emptykey = REQUIRED . find { sparams [ _1 ] . empty? } )
213+ raise DataFormatError , "Server didn't send %p (%p)" % [ emptykey , challenge ]
214+ elsif ( multikey = NO_MULTIPLES . find { sparams [ _1 ] . length > 1 } )
215+ raise DataFormatError , "Server sent multiple %p (%p)" % [ multikey , challenge ]
216+ end
217+ end
218+
219+ def stage_one_response
220+ response = {
221+ nonce : nonce ,
222+ username : username ,
223+ realm : realm ,
224+ cnonce : SecureRandom . base64 ( 32 ) ,
225+ "digest-uri" : digest_uri ,
226+ qop : "auth" ,
227+ maxbuf : 65535 ,
228+ nc : "%08d" % nc ( nonce ) ,
229+ charset : charset ,
230+ }
231+
232+ response [ :authzid ] = authzid unless authzid . nil?
233+ response [ :response ] = compute_digest ( response )
234+
235+ format_response ( response )
236+ end
237+
238+ def process_stage_two ( challenge )
239+ raise ResponseParseError , challenge unless challenge =~ /rspauth=/
240+ end
241+
146242 def nc ( nonce )
147- if @nc . has_key? nonce
148- @nc [ nonce ] = @nc [ nonce ] + 1
149- else
150- @nc [ nonce ] = 1
243+ @nc [ nonce ] = @nc . key? ( nonce ) ? @nc [ nonce ] + 1 : 1
244+ @nc [ nonce ]
245+ end
246+
247+ def compute_digest ( response )
248+ a1 = compute_a1 ( response )
249+ a2 = compute_a2 ( response )
250+ Digest ::MD5 . hexdigest (
251+ [
252+ Digest ::MD5 . hexdigest ( a1 ) ,
253+ response . values_at ( :nonce , :nc , :cnonce , :qop ) ,
254+ Digest ::MD5 . hexdigest ( a2 )
255+ ] . join ( ":" )
256+ )
257+ end
258+
259+ def compute_a0 ( response )
260+ Digest ::MD5 . digest (
261+ [ response . values_at ( :username , :realm ) , password ] . join ( ":" )
262+ )
263+ end
264+
265+ def compute_a1 ( response )
266+ a0 = compute_a0 ( response )
267+ a1 = [ a0 , response . values_at ( :nonce , :cnonce ) ] . join ( ":" )
268+ a1 << ":#{ response [ :authzid ] } " unless response [ :authzid ] . nil?
269+ a1
270+ end
271+
272+ def compute_a2 ( response )
273+ a2 = "AUTHENTICATE:#{ response [ :"digest-uri" ] } "
274+ if response [ :qop ] and response [ :qop ] =~ /^auth-(?:conf|int)$/
275+ a2 << ":00000000000000000000000000000000"
276+ end
277+ a2
278+ end
279+
280+ # Directives which must not have multiples. The RFC states:
281+ # >>>
282+ # This directive may appear at most once; if multiple instances are present,
283+ # the client should abort the authentication exchange.
284+ NO_MULTIPLES = %w[ nonce stale maxbuf charset algorithm ] . freeze
285+
286+ # Required directives which must occur exactly once. The RFC states: >>>
287+ # This directive is required and MUST appear exactly once; if not present,
288+ # or if multiple instances are present, the client should abort the
289+ # authentication exchange.
290+ REQUIRED = %w[ nonce algorithm ] . freeze
291+
292+ # Directives which are composed of one or more comma delimited tokens
293+ QUOTED_LISTABLE = %w[ qop cipher ] . freeze
294+
295+ private_constant :NO_MULTIPLES , :REQUIRED , :QUOTED_LISTABLE
296+
297+ LWS = /[\r \n \t ]*/n # less strict than RFC, more strict than '\s'
298+ TOKEN = /[^\x00 -\x20 \x7f ()<>@,;:\\ "\/ \[ \] ?={}]+/n
299+ QUOTED_STR = /"(?: [\t \x20 -\x7e &&[^"]] | \\ [\x00 -\x7f ] )*"/nx
300+ LIST_DELIM = /(?:#{ LWS } , )+ #{ LWS } /nx
301+ AUTH_PARAM = /
302+ (#{ TOKEN } ) #{ LWS } = #{ LWS } (#{ QUOTED_STR } | #{ TOKEN } ) #{ LIST_DELIM } ?
303+ /nx
304+
305+ private_constant :LWS , :TOKEN , :QUOTED_STR , :LIST_DELIM , :AUTH_PARAM
306+
307+ def parse_challenge ( challenge )
308+ sparams = Hash . new { |h , k | h [ k ] = [ ] }
309+ c = StringScanner . new ( challenge )
310+ c . skip LIST_DELIM
311+ while c . scan AUTH_PARAM
312+ k , v = c [ 1 ] , c [ 2 ]
313+ k = k . downcase
314+ if v =~ /\A "(.*)"\z /mn
315+ v = $1. gsub ( /\\ (.)/mn , '\1' )
316+ v = split_quoted_list ( v , challenge ) if QUOTED_LISTABLE . include? k
317+ end
318+ sparams [ k ] << v
319+ end
320+ c . eos? or raise DataFormatError , "Bad Challenge: %p" % [ challenge ]
321+ sparams . any? or raise DataFormatError , "Bad Challenge: %p" % [ challenge ]
322+ sparams
323+ end
324+
325+ def split_quoted_list ( value , challenge )
326+ value . split ( LIST_DELIM ) . reject ( &:empty? ) . tap do
327+ _1 . any? or raise DataFormatError , "Bad Challenge: %p" % [ challenge ]
151328 end
152- return @nc [ nonce ]
329+ end
330+
331+ def format_response ( response )
332+ response
333+ . keys
334+ . map { |key | qdval ( key . to_s , response [ key ] ) }
335+ . join ( "," )
153336 end
154337
155338 # some responses need quoting
156- def qdval ( k , v )
157- return if k . nil? or v . nil?
158- if %w" username authzid realm nonce cnonce digest-uri qop " . include? k
159- v = v . gsub ( /([\\ "])/ , "\\ \1 " )
160- return '%s="%s"' % [ k , v ]
339+ def qdval ( key , val )
340+ return if key . nil? or val . nil?
341+ if %w[ username authzid realm nonce cnonce digest-uri qop ] . include? key
342+ val = val . gsub ( /([\\ "])/n , "\\ \1 " )
343+ '%s="%s"' % [ key , val ]
161344 else
162- return ' %s=%s' % [ k , v ]
345+ " %s=%s" % [ key , val ]
163346 end
164347 end
165348
0 commit comments