diff --git a/lib/net/imap/sasl/digest_md5_authenticator.rb b/lib/net/imap/sasl/digest_md5_authenticator.rb
index 59080368..2bf08654 100644
--- a/lib/net/imap/sasl/digest_md5_authenticator.rb
+++ b/lib/net/imap/sasl/digest_md5_authenticator.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Net::IMAP authenticator for the "`DIGEST-MD5`" SASL mechanism type, specified
+# Net::IMAP authenticator for the +DIGEST-MD5+ SASL mechanism type, specified
# in RFC-2831[https://tools.ietf.org/html/rfc2831]. See Net::IMAP#authenticate.
#
# == Deprecated
@@ -9,11 +9,32 @@
# RFC-6331[https://tools.ietf.org/html/rfc6331] and should not be relied on for
# security. It is included for compatibility with existing servers.
class Net::IMAP::SASL::DigestMD5Authenticator
+ DataFormatError = Net::IMAP::DataFormatError
+ ResponseParseError = Net::IMAP::ResponseParseError
+ private_constant :DataFormatError, :ResponseParseError
+
STAGE_ONE = :stage_one
STAGE_TWO = :stage_two
STAGE_DONE = :stage_done
private_constant :STAGE_ONE, :STAGE_TWO, :STAGE_DONE
+ # Directives which must not have multiples. The RFC states:
+ # >>>
+ # This directive may appear at most once; if multiple instances are present,
+ # the client should abort the authentication exchange.
+ NO_MULTIPLES = %w[nonce stale maxbuf charset algorithm].freeze
+
+ # Required directives which must occur exactly once. The RFC states: >>>
+ # This directive is required and MUST appear exactly once; if not present,
+ # or if multiple instances are present, the client should abort the
+ # authentication exchange.
+ REQUIRED = %w[nonce algorithm].freeze
+
+ # Directives which are composed of one or more comma delimited tokens
+ QUOTED_LISTABLE = %w[qop cipher].freeze
+
+ private_constant :NO_MULTIPLES, :REQUIRED, :QUOTED_LISTABLE
+
# Authentication identity: the identity that matches the #password.
#
# RFC-2831[https://tools.ietf.org/html/rfc2831] uses the term +username+.
@@ -42,6 +63,59 @@ class Net::IMAP::SASL::DigestMD5Authenticator
#
attr_reader :authzid
+ # A namespace or collection of identities which contains +username+.
+ #
+ # Used by DIGEST-MD5, GSS-API, and NTLM. This is often a domain name that
+ # contains the name of the host performing the authentication.
+ #
+ # Defaults to the last realm in the server-provided list of
+ # realms.
+ attr_reader :realm
+
+ # Fully qualified canonical DNS host name for the requested service.
+ #
+ # Defaults to #realm.
+ attr_reader :host
+
+ # The service protocol, a
+ # {registered GSSAPI service name}[https://www.iana.org/assignments/gssapi-service-names/gssapi-service-names.xhtml],
+ # e.g. "imap", "ldap", or "xmpp".
+ #
+ # For Net::IMAP, the default is "imap" and should not be overridden. This
+ # must be set appropriately to use authenticators in other protocols.
+ #
+ # If an IANA-registered name isn't available, GSS-API
+ # (RFC-2743[https://tools.ietf.org/html/rfc2743]) allows the generic name
+ # "host".
+ attr_reader :service
+
+ # The generic server name when the server is replicated.
+ #
+ # +service_name+ will be ignored when it is +nil+ or identical to +host+.
+ #
+ # From RFC-2831[https://tools.ietf.org/html/rfc2831]:
+ # >>>
+ # The service is considered to be replicated if the client's
+ # service-location process involves resolution using standard DNS lookup
+ # operations, and if these operations involve DNS records (such as SRV, or
+ # MX) which resolve one DNS name into a set of other DNS names. In this
+ # case, the initial name used by the client is the "serv-name", and the
+ # final name is the "host" component.
+ attr_reader :service_name
+
+ # Parameters sent by the server are stored in this hash.
+ attr_reader :sparams
+
+ # The charset sent by the server. "UTF-8" (case insensitive) is the only
+ # allowed value. +nil+ should be interpreted as ISO 8859-1.
+ attr_reader :charset
+
+ # nonce sent by the server
+ attr_reader :nonce
+
+ # qop-options sent by the server
+ attr_reader :qop
+
# :call-seq:
# new(username, password, authzid = nil, **options) -> authenticator
# new(username:, password:, authzid: nil, **options) -> authenticator
@@ -64,12 +138,23 @@ class Net::IMAP::SASL::DigestMD5Authenticator
# When +authzid+ is not set, the server should derive the authorization
# identity from the authentication identity.
#
+ # * _optional_ #realm — A namespace for the #username, e.g. a domain.
+ # Defaults to the last realm in the server-provided realms list.
+ # * _optional_ #host — FQDN for requested service.
+ # Defaults to #realm.
+ # * _optional_ #service_name — The generic host name when the server is
+ # replicated.
+ # * _optional_ #service — the registered service protocol. E.g. "imap",
+ # "smtp", "ldap", "xmpp".
+ # For Net::IMAP, this defaults to "imap".
+ #
# * _optional_ +warn_deprecation+ — Set to +false+ to silence the warning.
#
# Any other keyword arguments are silently ignored.
def initialize(user = nil, pass = nil, authz = nil,
username: nil, password: nil, authzid: nil,
authcid: nil, secret: nil,
+ realm: nil, service: "imap", host: nil, service_name: nil,
warn_deprecation: true, **)
username = authcid || username || user or
raise ArgumentError, "missing username (authcid)"
@@ -77,14 +162,34 @@ def initialize(user = nil, pass = nil, authz = nil,
authzid ||= authz
if warn_deprecation
warn "WARNING: DIGEST-MD5 SASL mechanism was deprecated by RFC6331."
- # TODO: recommend SCRAM instead.
end
+
require "digest/md5"
+ require "securerandom"
require "strscan"
@username, @password, @authzid = username, password, authzid
+ @realm = realm
+ @host = host
+ @service = service
+ @service_name = service_name
@nc, @stage = {}, STAGE_ONE
end
+ # From RFC-2831[https://tools.ietf.org/html/rfc2831]:
+ # >>>
+ # Indicates the principal name of the service with which the client wishes
+ # to connect, formed from the serv-type, host, and serv-name. For
+ # example, the FTP service on "ftp.example.com" would have a "digest-uri"
+ # value of "ftp/ftp.example.com"; the SMTP server from the example above
+ # would have a "digest-uri" value of "smtp/mail3.example.com/example.com".
+ def digest_uri
+ if service_name && service_name != host
+ "#{service}/#{host}/#{service_name}"
+ else
+ "#{service}/#{host}"
+ end
+ end
+
def initial_response?; false end
# Responds to server challenge in two stages.
@@ -92,65 +197,49 @@ def process(challenge)
case @stage
when STAGE_ONE
@stage = STAGE_TWO
- sparams = {}
- c = StringScanner.new(challenge)
- while c.scan(/(?:\s*,)?\s*(\w+)=("(?:[^\\"]|\\.)*"|[^,]+)\s*/)
- k, v = c[1], c[2]
- if v =~ /^"(.*)"$/
- v = $1
- if v =~ /,/
- v = v.split(',')
- end
- end
- sparams[k] = v
- end
+ @sparams = parse_challenge(challenge)
+ @qop = sparams.key?("qop") ? ["auth"] : sparams["qop"].flatten
+ @nonce = sparams["nonce"] &.first
+ @charset = sparams["charset"]&.first
+ @realm ||= sparams["realm"] &.last
+ @host ||= realm
- raise Net::IMAP::DataFormatError, "Bad Challenge: '#{challenge}'" unless c.eos? and sparams['qop']
- raise Net::IMAP::Error, "Server does not support auth (qop = #{sparams['qop'].join(',')})" unless sparams['qop'].include?("auth")
+ if !qop.include?("auth")
+ raise DataFormatError, "Server does not support auth (qop = %p)" % [
+ sparams["qop"]
+ ]
+ elsif (emptykey = REQUIRED.find { sparams[_1].empty? })
+ raise DataFormatError, "Server didn't send %s (%p)" % [emptykey, challenge]
+ elsif (multikey = NO_MULTIPLES.find { sparams[_1].length > 1 })
+ raise DataFormatError, "Server sent multiple %s (%p)" % [multikey, challenge]
+ end
response = {
- :nonce => sparams['nonce'],
- :username => @username,
- :realm => sparams['realm'],
- :cnonce => Digest::MD5.hexdigest("%.15f:%.15f:%d" % [Time.now.to_f, rand, Process.pid.to_s]),
- :'digest-uri' => 'imap/' + sparams['realm'],
- :qop => 'auth',
- :maxbuf => 65535,
- :nc => "%08d" % nc(sparams['nonce']),
- :charset => sparams['charset'],
+ nonce: nonce,
+ username: username,
+ realm: realm,
+ cnonce: SecureRandom.base64(32),
+ "digest-uri": digest_uri,
+ qop: "auth",
+ maxbuf: 65535,
+ nc: "%08d" % nc(nonce),
+ charset: charset,
}
response[:authzid] = @authzid unless @authzid.nil?
- # now, the real thing
- a0 = Digest::MD5.digest( [ response.values_at(:username, :realm), @password ].join(':') )
-
- a1 = [ a0, response.values_at(:nonce,:cnonce) ].join(':')
- a1 << ':' + response[:authzid] unless response[:authzid].nil?
-
- a2 = "AUTHENTICATE:" + response[:'digest-uri']
- a2 << ":00000000000000000000000000000000" if response[:qop] and response[:qop] =~ /^auth-(?:conf|int)$/
-
- response[:response] = Digest::MD5.hexdigest(
- [
- Digest::MD5.hexdigest(a1),
- response.values_at(:nonce, :nc, :cnonce, :qop),
- Digest::MD5.hexdigest(a2)
- ].join(':')
- )
-
- return response.keys.map {|key| qdval(key.to_s, response[key]) }.join(',')
+ response[:response] = response_value(response)
+ format_response(response)
when STAGE_TWO
@stage = STAGE_DONE
- # if at the second stage, return an empty string
- if challenge =~ /rspauth=/
- return ''
- else
- raise ResponseParseError, challenge
- end
+ raise ResponseParseError, challenge unless challenge =~ /rspauth=/
+ "" # if at the second stage, return an empty string
else
raise ResponseParseError, challenge
end
+ rescue => error
+ @stage = error
+ raise
end
def done?; @stage == STAGE_DONE end
@@ -163,7 +252,79 @@ def nc(nonce)
else
@nc[nonce] = 1
end
- return @nc[nonce]
+ end
+
+ def response_value(response)
+ a1 = compute_a1(response)
+ a2 = compute_a2(response)
+ Digest::MD5.hexdigest(
+ [
+ Digest::MD5.hexdigest(a1),
+ response.values_at(:nonce, :nc, :cnonce, :qop),
+ Digest::MD5.hexdigest(a2)
+ ].join(":")
+ )
+ end
+
+ def compute_a0(response)
+ Digest::MD5.digest(
+ [ response.values_at(:username, :realm), password ].join(":")
+ )
+ end
+
+ def compute_a1(response)
+ a0 = compute_a0(response)
+ a1 = [ a0, response.values_at(:nonce, :cnonce) ].join(":")
+ a1 << ":#{response[:authzid]}" unless response[:authzid].nil?
+ a1
+ end
+
+ def compute_a2(response)
+ a2 = "AUTHENTICATE:#{response[:"digest-uri"]}"
+ if response[:qop] and response[:qop] =~ /^auth-(?:conf|int)$/
+ a2 << ":00000000000000000000000000000000"
+ end
+ a2
+ end
+
+ LWS = /[\r\n \t]*/n # less strict than RFC, more strict than '\s'
+ TOKEN = /[^\x00-\x20\x7f()<>@,;:\\"\/\[\]?={}]+/n
+ QUOTED_STR = /"(?: [\t\x20-\x7e&&[^"]] | \\[\x00-\x7f] )*"/nx
+ LIST_DELIM = /(?:#{LWS} , )+ #{LWS}/nx
+ AUTH_PARAM = /
+ (#{TOKEN}) #{LWS} = #{LWS} (#{QUOTED_STR} | #{TOKEN}) #{LIST_DELIM}?
+ /nx
+ private_constant :LWS, :TOKEN, :QUOTED_STR, :LIST_DELIM, :AUTH_PARAM
+
+ def parse_challenge(challenge)
+ sparams = Hash.new {|h, k| h[k] = [] }
+ c = StringScanner.new(challenge)
+ c.skip LIST_DELIM
+ while c.scan AUTH_PARAM
+ k, v = c[1], c[2]
+ k = k.downcase
+ if v =~ /\A"(.*)"\z/mn
+ v = $1.gsub(/\\(.)/mn, '\1')
+ v = split_quoted_list(v, challenge) if QUOTED_LISTABLE.include? k
+ end
+ sparams[k] << v
+ end
+ if !c.eos?
+ raise DataFormatError, "Unparsable challenge: %p" % [challenge]
+ elsif sparams.empty?
+ raise DataFormatError, "Empty challenge: %p" % [challenge]
+ end
+ sparams
+ end
+
+ def split_quoted_list(value, challenge)
+ value.split(LIST_DELIM).reject(&:empty?).tap do
+ _1.any? or raise DataFormatError, "Bad Challenge: %p" % [challenge]
+ end
+ end
+
+ def format_response(response)
+ response.map {|k, v| qdval(k.to_s, v) }.join(",")
end
# some responses need quoting
diff --git a/test/net/imap/test_imap_authenticators.rb b/test/net/imap/test_imap_authenticators.rb
index e7faf36d..b2c92b4c 100644
--- a/test/net/imap/test_imap_authenticators.rb
+++ b/test/net/imap/test_imap_authenticators.rb
@@ -386,7 +386,7 @@ def test_digest_md5_authenticator_matches_mechanism
def test_digest_md5_authenticator_deprecated
assert_warn(/DIGEST-MD5.+deprecated.+RFC6331/) do
- Net::IMAP::SASL.authenticator("DIGEST-MD5", "user", "pass")
+ Net::IMAP.authenticator("DIGEST-MD5", "user", "pass")
end
end
@@ -422,20 +422,89 @@ def test_digest_md5_authenticator
)
end
- def test_digest_md5_authenticator_garbage
+ def test_digest_md5_authenticator_realm_and_digest_uri
+ auth = digest_md5(authcid: "authc",
+ authzid: "authz",
+ password: "pass",
+ realm: "myrealm",
+ service: "smtp",
+ host: "mail.example.com",
+ service_name: "example.com")
+ assert_match(
+ %r{\A
+ nonce="OA6MG9tEQGm2hh",
+ username="authc",
+ realm="myrealm",
+ cnonce="[a-zA-Z0-9+/]{12,}={0,3}",
+ digest-uri="smtp/mail\.example\.com/example\.com",
+ qop="auth",
+ maxbuf=65535,
+ nc=00000001,
+ charset=utf-8,
+ authzid="authz",
+ response=[a-f0-9]+
+ \Z}x,
+ auth.process(
+ %w[
+ realm="somerealm"
+ nonce="OA6MG9tEQGm2hh"
+ qop="auth"
+ charset=utf-8
+ algorithm=md5-sess
+ ].join(",")
+ )
+ )
+ end
+
+ def test_digest_md5_authenticator_empty_challenge
auth = digest_md5("user", "pass")
assert_raise(Net::IMAP::DataFormatError) do
+ auth.process(" ")
+ end
+ end
+
+ def test_digest_md5_authenticator_empty_challenge_commas
+ auth = digest_md5("user", "pass")
+ assert_raise_with_message(Net::IMAP::DataFormatError, /empty challenge/i) do
+ auth.process(" , , ")
+ end
+ end
+
+ def test_digest_md5_authenticator_garbage_no_equal_sign
+ auth = digest_md5("user", "pass")
+ assert_raise_with_message(Net::IMAP::DataFormatError, /unparsable/i) do
+ auth.process('nonce=required,algorithm=md5-sess,foo')
+ end
+ end
+
+ def test_digest_md5_authenticator_qdstr_with_comma
+ auth = digest_md5("user", "pass")
+ assert_raise_with_message(Net::IMAP::DataFormatError, /unparsable/i) do
+ auth.process('nonce=required,algorithm=md5-sess,.')
+ end
+ end
+
+ def test_digest_md5_authenticator_garbage
+ auth = digest_md5("user", "pass")
+ assert_raise_with_message(Net::IMAP::DataFormatError, /unparsable/i) do
auth.process('.')
end
end
- def test_digest_md5_authenticator_no_qop
+ def test_digest_md5_authenticator_empty_qop
auth = digest_md5("user", "pass")
- assert_raise(Net::IMAP::DataFormatError) do
+ assert_raise_with_message(Net::IMAP::DataFormatError, /bad challenge/i) do
auth.process('Qop=""')
end
end
+ def test_digest_md5_authenticator_missing_nonce
+ auth = digest_md5("user", "pass")
+ assert_raise_with_message(Net::IMAP::DataFormatError, /didn't send nonce/i) do
+ auth.process('Qop="auth"')
+ end
+ end
+
def test_digest_md5_authenticator_illinear
pre = ->(n) {'qop="a' + ',x'*n}
assert_linear_performance([5, 10, 15, 20], pre: pre) do |challenge|