# frozen_string_literal: true
# Net::IMAP authenticator for the +DIGEST-MD5+ SASL mechanism type, specified
# in RFC-2831[https://www.rfc-editor.org/rfc/rfc2831]. See Net::IMAP#authenticate.
#
# == Deprecated
#
# "+DIGEST-MD5+" has been deprecated by
# RFC-6331[https://www.rfc-editor.org/rfc/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://www.rfc-editor.org/rfc/rfc2831] uses the term +username+.
# "Authentication identity" is the generic term used by
# RFC-4422[https://www.rfc-editor.org/rfc/rfc4422].
# RFC-4616[https://www.rfc-editor.org/rfc/rfc4616] and many later RFCs abbreviate
# this to +authcid+.
attr_reader :username
alias authcid username
# A password or passphrase that matches the #username.
#
# The +password+ will be used to create the response digest.
attr_reader :password
# Authorization identity: an identity to act as or on behalf of. The identity
# form is application protocol specific. If not provided or left blank, the
# server derives an authorization identity from the authentication identity.
# The server is responsible for verifying the client's credentials and
# verifying that the identity it associates with the client's authentication
# identity is allowed to act as (or on behalf of) the authorization identity.
#
# For example, an administrator or superuser might take on another role:
#
# imap.authenticate "DIGEST-MD5", "root", ->{passwd}, authzid: "user"
#
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://www.rfc-editor.org/rfc/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://www.rfc-editor.org/rfc/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
# new(authcid:, password:, authzid: nil, **options) -> authenticator
#
# Creates an Authenticator for the "+DIGEST-MD5+" SASL mechanism.
#
# Called by Net::IMAP#authenticate and similar methods on other clients.
#
# ==== Parameters
#
# * #authcid ― Authentication identity that is associated with #password.
#
# #username ― An alias for +authcid+.
#
# * #password ― A password or passphrase associated with this #authcid.
#
# * _optional_ #authzid ― Authorization identity to act as or on behalf of.
#
# 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)"
password ||= secret || pass or raise ArgumentError, "missing password"
authzid ||= authz
if warn_deprecation
warn("WARNING: DIGEST-MD5 SASL mechanism was deprecated by RFC6331.",
category: :deprecated)
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://www.rfc-editor.org/rfc/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.
def process(challenge)
case @stage
when STAGE_ONE
@stage = STAGE_TWO
@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
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: 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?
response[:response] = response_value(response)
format_response(response)
when STAGE_TWO
@stage = STAGE_DONE
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
private
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 nc(nonce)
if @nc.has_key? nonce
@nc[nonce] = @nc[nonce] + 1
else
@nc[nonce] = 1
end
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
def format_response(response)
response.map {|k, v| qdval(k.to_s, v) }.join(",")
end
# some responses need quoting
def qdval(k, v)
return if k.nil? or v.nil?
if %w"username authzid realm nonce cnonce digest-uri qop".include? k
v = v.gsub(/([\\"])/, "\\\1")
return '%s="%s"' % [k, v]
else
return '%s=%s' % [k, v]
end
end
end