Skip to content

Commit 496c8f3

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 for experimenting with other SASL changes. It's complexity vs most other mechanisms makes it a good test-bed for the completeness of net-imap's SASL implementation: e.g: it demonstrated that we were missing features such as `done?`, demonstrates the utility of using callbacks for attributes such as `realm` (the user might select from a server-provided list), it shows that `service` cannot be hard-coded to `imap` and must be provided by the client, and requires other attributes that should be provided by the client such as `host`, `port` (also used by `OAUTHBEARER`). I improved the existing authenticator in several ways: * ✨ User can configure `realm`, `host`, `service_name`, `service`. This allows a correct "digest-uri" for non-IMAP clients. * 🔒 Use SecureRandom for cnonce (not Time.now + insecure PRNG!) * ✨ Default `qop=auth` (as in RFC) * ✨ Enforce requirements for `sparam` keys (required and no-multiples). * ♻️ Refactor toward the style used in the new ScramAuthenticator. However... it's still deprecated, so don't use it! 🙃
1 parent 5a72913 commit 496c8f3

File tree

2 files changed

+322
-77
lines changed

2 files changed

+322
-77
lines changed

lib/net/imap/sasl/digest_md5_authenticator.rb

Lines changed: 257 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
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
@@ -9,6 +9,10 @@
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+
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,88 +113,77 @@ 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
def initial_response?; false end
77175

78176
# Responds to server challenge in two stages.
79177
def process(challenge)
80178
case @stage
81179
when STAGE_ONE
82180
@stage = STAGE_TWO
83-
sparams = {}
84-
c = StringScanner.new(challenge)
85-
while c.scan(/(?:\s*,)?\s*(\w+)=("(?:[^\\"]|\\.)*"|[^,]+)\s*/)
86-
k, v = c[1], c[2]
87-
if v =~ /^"(.*)"$/
88-
v = $1
89-
if v =~ /,/
90-
v = v.split(',')
91-
end
92-
end
93-
sparams[k] = v
94-
end
95-
96-
raise Net::IMAP::DataFormatError, "Bad Challenge: '#{challenge}'" unless c.eos? and sparams['qop']
97-
raise Net::IMAP::Error, "Server does not support auth (qop = #{sparams['qop'].join(',')})" unless sparams['qop'].include?("auth")
98-
99-
response = {
100-
:nonce => sparams['nonce'],
101-
:username => @username,
102-
:realm => sparams['realm'],
103-
:cnonce => Digest::MD5.hexdigest("%.15f:%.15f:%d" % [Time.now.to_f, rand, Process.pid.to_s]),
104-
:'digest-uri' => 'imap/' + sparams['realm'],
105-
:qop => 'auth',
106-
:maxbuf => 65535,
107-
:nc => "%08d" % nc(sparams['nonce']),
108-
:charset => sparams['charset'],
109-
}
110-
111-
response[:authzid] = @authzid unless @authzid.nil?
112-
113-
# now, the real thing
114-
a0 = Digest::MD5.digest( [ response.values_at(:username, :realm), @password ].join(':') )
115-
116-
a1 = [ a0, response.values_at(:nonce,:cnonce) ].join(':')
117-
a1 << ':' + response[:authzid] unless response[:authzid].nil?
118-
119-
a2 = "AUTHENTICATE:" + response[:'digest-uri']
120-
a2 << ":00000000000000000000000000000000" if response[:qop] and response[:qop] =~ /^auth-(?:conf|int)$/
121-
122-
response[:response] = Digest::MD5.hexdigest(
123-
[
124-
Digest::MD5.hexdigest(a1),
125-
response.values_at(:nonce, :nc, :cnonce, :qop),
126-
Digest::MD5.hexdigest(a2)
127-
].join(':')
128-
)
129-
130-
return response.keys.map {|key| qdval(key.to_s, response[key]) }.join(',')
181+
process_stage_one(challenge)
182+
stage_one_response
131183
when STAGE_TWO
132184
@stage = STAGE_DONE
133-
# if at the second stage, return an empty string
134-
if challenge =~ /rspauth=/
135-
return ''
136-
else
137-
raise ResponseParseError, challenge
138-
end
185+
process_stage_two(challenge)
186+
"" # if at the second stage, return an empty string
139187
else
140188
raise ResponseParseError, challenge
141189
end
@@ -145,23 +193,158 @@ def done?; @stage == STAGE_DONE end
145193

146194
private
147195

196+
def process_stage_one(challenge)
197+
@sparams = parse_challenge(challenge)
198+
@qop = sparams.key?("qop") ? ["auth"] : sparams["qop"].flatten
199+
200+
guard_stage_one(challenge)
201+
202+
@nonce = sparams["nonce"] .first
203+
@charset = sparams["charset"].first
204+
205+
@realm ||= sparams["realm"].last
206+
@host ||= realm
207+
end
208+
209+
def guard_stage_one(challenge)
210+
if !qop.include?("auth")
211+
raise DataFormatError, "Server does not support auth (qop = %p)" % [
212+
sparams["qop"]
213+
]
214+
elsif (emptykey = REQUIRED.find { sparams[_1].empty? })
215+
raise DataFormatError, "Server didn't send %p (%p)" % [emptykey, challenge]
216+
elsif (multikey = NO_MULTIPLES.find { sparams[_1].length > 1 })
217+
raise DataFormatError, "Server sent multiple %p (%p)" % [multikey, challenge]
218+
end
219+
end
220+
221+
def stage_one_response
222+
response = {
223+
nonce: nonce,
224+
username: username,
225+
realm: realm,
226+
cnonce: SecureRandom.base64(32),
227+
"digest-uri": digest_uri,
228+
qop: "auth",
229+
maxbuf: 65535,
230+
nc: "%08d" % nc(nonce),
231+
charset: charset,
232+
}
233+
234+
response[:authzid] = authzid unless authzid.nil?
235+
response[:response] = compute_digest(response)
236+
237+
format_response(response)
238+
end
239+
240+
def process_stage_two(challenge)
241+
raise ResponseParseError, challenge unless challenge =~ /rspauth=/
242+
end
243+
148244
def nc(nonce)
149-
if @nc.has_key? nonce
150-
@nc[nonce] = @nc[nonce] + 1
151-
else
152-
@nc[nonce] = 1
245+
@nc[nonce] = @nc.key?(nonce) ? @nc[nonce] + 1 : 1
246+
@nc[nonce]
247+
end
248+
249+
def compute_digest(response)
250+
a1 = compute_a1(response)
251+
a2 = compute_a2(response)
252+
Digest::MD5.hexdigest(
253+
[
254+
Digest::MD5.hexdigest(a1),
255+
response.values_at(:nonce, :nc, :cnonce, :qop),
256+
Digest::MD5.hexdigest(a2)
257+
].join(":")
258+
)
259+
end
260+
261+
def compute_a0(response)
262+
Digest::MD5.digest(
263+
[ response.values_at(:username, :realm), password ].join(":")
264+
)
265+
end
266+
267+
def compute_a1(response)
268+
a0 = compute_a0(response)
269+
a1 = [ a0, response.values_at(:nonce, :cnonce) ].join(":")
270+
a1 << ":#{response[:authzid]}" unless response[:authzid].nil?
271+
a1
272+
end
273+
274+
def compute_a2(response)
275+
a2 = "AUTHENTICATE:#{response[:"digest-uri"]}"
276+
if response[:qop] and response[:qop] =~ /^auth-(?:conf|int)$/
277+
a2 << ":00000000000000000000000000000000"
278+
end
279+
a2
280+
end
281+
282+
# Directives which must not have multiples. The RFC states:
283+
# >>>
284+
# This directive may appear at most once; if multiple instances are present,
285+
# the client should abort the authentication exchange.
286+
NO_MULTIPLES = %w[nonce stale maxbuf charset algorithm].freeze
287+
288+
# Required directives which must occur exactly once. The RFC states: >>>
289+
# This directive is required and MUST appear exactly once; if not present,
290+
# or if multiple instances are present, the client should abort the
291+
# authentication exchange.
292+
REQUIRED = %w[nonce algorithm].freeze
293+
294+
# Directives which are composed of one or more comma delimited tokens
295+
QUOTED_LISTABLE = %w[qop cipher].freeze
296+
297+
private_constant :NO_MULTIPLES, :REQUIRED, :QUOTED_LISTABLE
298+
299+
LWS = /[\r\n \t]*/n # less strict than RFC, more strict than '\s'
300+
TOKEN = /[^\x00-\x20\x7f()<>@,;:\\"\/\[\]?={}]+/n
301+
QUOTED_STR = /"(?: [\t\x20-\x7e&&[^"]] | \\[\x00-\x7f] )*"/nx
302+
LIST_DELIM = /(?:#{LWS} , )+ #{LWS}/nx
303+
AUTH_PARAM = /
304+
(#{TOKEN}) #{LWS} = #{LWS} (#{QUOTED_STR} | #{TOKEN}) #{LIST_DELIM}?
305+
/nx
306+
307+
private_constant :LWS, :TOKEN, :QUOTED_STR, :LIST_DELIM, :AUTH_PARAM
308+
309+
def parse_challenge(challenge)
310+
sparams = Hash.new {|h, k| h[k] = [] }
311+
c = StringScanner.new(challenge)
312+
c.skip LIST_DELIM
313+
while c.scan AUTH_PARAM
314+
k, v = c[1], c[2]
315+
k = k.downcase
316+
if v =~ /\A"(.*)"\z/mn
317+
v = $1.gsub(/\\(.)/mn, '\1')
318+
v = split_quoted_list(v, challenge) if QUOTED_LISTABLE.include? k
319+
end
320+
sparams[k] << v
321+
end
322+
c.eos? or raise DataFormatError, "Bad Challenge: %p" % [challenge]
323+
sparams.any? or raise DataFormatError, "Bad Challenge: %p" % [challenge]
324+
sparams
325+
end
326+
327+
def split_quoted_list(value, challenge)
328+
value.split(LIST_DELIM).reject(&:empty?).tap do
329+
_1.any? or raise DataFormatError, "Bad Challenge: %p" % [challenge]
153330
end
154-
return @nc[nonce]
331+
end
332+
333+
def format_response(response)
334+
response
335+
.keys
336+
.map {|key| qdval(key.to_s, response[key]) }
337+
.join(",")
155338
end
156339

157340
# some responses need quoting
158-
def qdval(k, v)
159-
return if k.nil? or v.nil?
160-
if %w"username authzid realm nonce cnonce digest-uri qop".include? k
161-
v = v.gsub(/([\\"])/, "\\\1")
162-
return '%s="%s"' % [k, v]
341+
def qdval(key, val)
342+
return if key.nil? or val.nil?
343+
if %w[username authzid realm nonce cnonce digest-uri qop].include? key
344+
val = val.gsub(/([\\"])/n, "\\\1")
345+
'%s="%s"' % [key, val]
163346
else
164-
return '%s=%s' % [k, v]
347+
"%s=%s" % [key, val]
165348
end
166349
end
167350

0 commit comments

Comments
 (0)