Skip to content

Commit 0fbf55a

Browse files
committed
🔒 SASL DIGEST-MD5: realm, host, service_name, etc
Yes, DIGEST-MD5 is deprecated! But that also means that it was lower risk as a test-bed for refactoring a more complicated challenge/response SASL mechanism. I improved the existing authenticator it in several ways: * ♻️ Refactor to the style used in the new ScramAuthenticator. * 🔒 Use SecureRandom for cnonce (not Time.now + insecure PRNG!) * ✨ Default qop=auth (as in RFC) * ✨ User can configure realm, host, service_name, service. * This allows a correct "digest-uri" for non-IMAP clients. * ✨ Enforce requirements for sparam keys (required and no-multiples). However... it's still deprecated, so don't use it!
1 parent d192f6a commit 0fbf55a

File tree

2 files changed

+320
-76
lines changed

2 files changed

+320
-76
lines changed

lib/net/imap/sasl/digest_md5_authenticator.rb

Lines changed: 255 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
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.
1111
class 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

Comments
 (0)