Skip to content

Commit 725a10e

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. Its complexity vs most other mechanisms made it a good test-bed for the completeness of net-imap's SASL implementation. For example: * It demonstrated that we were missing features such as `done?`. * It demonstrates the utility of using callbacks for attributes such as `realm` (the user might select from a server-provided list). _Please note: the initial work I did to support attribute callbacks was reverted, to simplify the big SASL re-write. It could still be a useful feature for this and other mechanisms._ * It shows that `service` should not be hard-coded to `imap`, and should be provided by the client (or the protocol adapter). _Please note: Although the current (experimental) client adapters _do_ have a `#service` method, it is not used by the (experimental) AuthenticationExchange yet._ * It 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: * ✨ Add `realm`, `host`, `service_name`, `service` attributes. This allows non-IMAP clients to construct the correct `digest-uri`. * 🔒 Use SecureRandom for cnonce (not Time.now + insecure PRNG!) * ✨ Default `qop=auth` (as in RFC) * ✨ Enforce requirements for `sparam` keys (required and no-multiples). * ♻️ Various other minor refactorings. However... it's still deprecated, so don't use if you don't need to! 🙃
1 parent 8e9a213 commit 725a10e

File tree

2 files changed

+284
-54
lines changed

2 files changed

+284
-54
lines changed

lib/net/imap/sasl/digest_md5_authenticator.rb

Lines changed: 211 additions & 50 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,11 +9,32 @@
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
1519
private_constant :STAGE_ONE, :STAGE_TWO, :STAGE_DONE
1620

21+
# Directives which must not have multiples. The RFC states:
22+
# >>>
23+
# This directive may appear at most once; if multiple instances are present,
24+
# the client should abort the authentication exchange.
25+
NO_MULTIPLES = %w[nonce stale maxbuf charset algorithm].freeze
26+
27+
# Required directives which must occur exactly once. The RFC states: >>>
28+
# This directive is required and MUST appear exactly once; if not present,
29+
# or if multiple instances are present, the client should abort the
30+
# authentication exchange.
31+
REQUIRED = %w[nonce algorithm].freeze
32+
33+
# Directives which are composed of one or more comma delimited tokens
34+
QUOTED_LISTABLE = %w[qop cipher].freeze
35+
36+
private_constant :NO_MULTIPLES, :REQUIRED, :QUOTED_LISTABLE
37+
1738
# Authentication identity: the identity that matches the #password.
1839
#
1940
# RFC-2831[https://tools.ietf.org/html/rfc2831] uses the term +username+.
@@ -42,6 +63,59 @@ class Net::IMAP::SASL::DigestMD5Authenticator
4263
#
4364
attr_reader :authzid
4465

66+
# A namespace or collection of identities which contains +username+.
67+
#
68+
# Used by DIGEST-MD5, GSS-API, and NTLM. This is often a domain name that
69+
# contains the name of the host performing the authentication.
70+
#
71+
# <em>Defaults to the last realm in the server-provided list of
72+
# realms.</em>
73+
attr_reader :realm
74+
75+
# Fully qualified canonical DNS host name for the requested service.
76+
#
77+
# <em>Defaults to #realm.</em>
78+
attr_reader :host
79+
80+
# The service protocol, a
81+
# {registered GSSAPI service name}[https://www.iana.org/assignments/gssapi-service-names/gssapi-service-names.xhtml],
82+
# e.g. "imap", "ldap", or "xmpp".
83+
#
84+
# For Net::IMAP, the default is "imap" and should not be overridden. This
85+
# must be set appropriately to use authenticators in other protocols.
86+
#
87+
# If an IANA-registered name isn't available, GSS-API
88+
# (RFC-2743[https://tools.ietf.org/html/rfc2743]) allows the generic name
89+
# "host".
90+
attr_reader :service
91+
92+
# The generic server name when the server is replicated.
93+
#
94+
# +service_name+ will be ignored when it is +nil+ or identical to +host+.
95+
#
96+
# From RFC-2831[https://tools.ietf.org/html/rfc2831]:
97+
# >>>
98+
# The service is considered to be replicated if the client's
99+
# service-location process involves resolution using standard DNS lookup
100+
# operations, and if these operations involve DNS records (such as SRV, or
101+
# MX) which resolve one DNS name into a set of other DNS names. In this
102+
# case, the initial name used by the client is the "serv-name", and the
103+
# final name is the "host" component.
104+
attr_reader :service_name
105+
106+
# Parameters sent by the server are stored in this hash.
107+
attr_reader :sparams
108+
109+
# The charset sent by the server. "UTF-8" (case insensitive) is the only
110+
# allowed value. +nil+ should be interpreted as ISO 8859-1.
111+
attr_reader :charset
112+
113+
# nonce sent by the server
114+
attr_reader :nonce
115+
116+
# qop-options sent by the server
117+
attr_reader :qop
118+
45119
# :call-seq:
46120
# new(username, password, authzid = nil, **options) -> authenticator
47121
# new(username:, password:, authzid: nil, **options) -> authenticator
@@ -64,106 +138,193 @@ class Net::IMAP::SASL::DigestMD5Authenticator
64138
# When +authzid+ is not set, the server should derive the authorization
65139
# identity from the authentication identity.
66140
#
141+
# * _optional_ #realm — A namespace for the #username, e.g. a domain.
142+
# <em>Defaults to the last realm in the server-provided realms list.</em>
143+
# * _optional_ #host — FQDN for requested service.
144+
# <em>Defaults to</em> #realm.
145+
# * _optional_ #service_name — The generic host name when the server is
146+
# replicated.
147+
# * _optional_ #service — the registered service protocol. E.g. "imap",
148+
# "smtp", "ldap", "xmpp".
149+
# <em>For Net::IMAP, this defaults to "imap".</em>
150+
#
67151
# * _optional_ +warn_deprecation+ — Set to +false+ to silence the warning.
68152
#
69153
# Any other keyword arguments are silently ignored.
70154
def initialize(user = nil, pass = nil, authz = nil,
71155
username: nil, password: nil, authzid: nil,
72156
authcid: nil, secret: nil,
157+
realm: nil, service: "imap", host: nil, service_name: nil,
73158
warn_deprecation: true, **)
74159
username = authcid || username || user or
75160
raise ArgumentError, "missing username (authcid)"
76161
password ||= secret || pass or raise ArgumentError, "missing password"
77162
authzid ||= authz
78163
if warn_deprecation
79164
warn "WARNING: DIGEST-MD5 SASL mechanism was deprecated by RFC6331."
80-
# TODO: recommend SCRAM instead.
81165
end
166+
82167
require "digest/md5"
168+
require "securerandom"
83169
require "strscan"
84170
@username, @password, @authzid = username, password, authzid
171+
@realm = realm
172+
@host = host
173+
@service = service
174+
@service_name = service_name
85175
@nc, @stage = {}, STAGE_ONE
86176
end
87177

178+
# From RFC-2831[https://tools.ietf.org/html/rfc2831]:
179+
# >>>
180+
# Indicates the principal name of the service with which the client wishes
181+
# to connect, formed from the serv-type, host, and serv-name. For
182+
# example, the FTP service on "ftp.example.com" would have a "digest-uri"
183+
# value of "ftp/ftp.example.com"; the SMTP server from the example above
184+
# would have a "digest-uri" value of "smtp/mail3.example.com/example.com".
185+
def digest_uri
186+
if service_name && service_name != host
187+
"#{service}/#{host}/#{service_name}"
188+
else
189+
"#{service}/#{host}"
190+
end
191+
end
192+
88193
def initial_response?; false end
89194

90195
# Responds to server challenge in two stages.
91196
def process(challenge)
92197
case @stage
93198
when STAGE_ONE
94199
@stage = STAGE_TWO
95-
sparams = {}
96-
c = StringScanner.new(challenge)
97-
while c.scan(/(?:\s*,)?\s*(\w+)=("(?:[^\\"]|\\.)*"|[^,]+)\s*/)
98-
k, v = c[1], c[2]
99-
if v =~ /^"(.*)"$/
100-
v = $1
101-
if v =~ /,/
102-
v = v.split(',')
103-
end
104-
end
105-
sparams[k] = v
106-
end
200+
@sparams = parse_challenge(challenge)
201+
@qop = sparams.key?("qop") ? ["auth"] : sparams["qop"].flatten
202+
@nonce = sparams["nonce"] &.first
203+
@charset = sparams["charset"]&.first
204+
@realm ||= sparams["realm"] &.last
205+
@host ||= realm
107206

108-
raise Net::IMAP::DataFormatError, "Bad Challenge: '#{challenge}'" unless c.eos? and sparams['qop']
109-
raise Net::IMAP::Error, "Server does not support auth (qop = #{sparams['qop'].join(',')})" unless sparams['qop'].include?("auth")
207+
if !qop.include?("auth")
208+
raise DataFormatError, "Server does not support auth (qop = %p)" % [
209+
sparams["qop"]
210+
]
211+
elsif (emptykey = REQUIRED.find { sparams[_1].empty? })
212+
raise DataFormatError, "Server didn't send %s (%p)" % [emptykey, challenge]
213+
elsif (multikey = NO_MULTIPLES.find { sparams[_1].length > 1 })
214+
raise DataFormatError, "Server sent multiple %s (%p)" % [multikey, challenge]
215+
end
110216

111217
response = {
112-
:nonce => sparams['nonce'],
113-
:username => @username,
114-
:realm => sparams['realm'],
115-
:cnonce => Digest::MD5.hexdigest("%.15f:%.15f:%d" % [Time.now.to_f, rand, Process.pid.to_s]),
116-
:'digest-uri' => 'imap/' + sparams['realm'],
117-
:qop => 'auth',
118-
:maxbuf => 65535,
119-
:nc => "%08d" % nc(sparams['nonce']),
120-
:charset => sparams['charset'],
218+
nonce: nonce,
219+
username: username,
220+
realm: realm,
221+
cnonce: SecureRandom.base64(32),
222+
"digest-uri": digest_uri,
223+
qop: "auth",
224+
maxbuf: 65535,
225+
nc: "%08d" % nc(nonce),
226+
charset: charset,
121227
}
122228

123229
response[:authzid] = @authzid unless @authzid.nil?
124230

125-
# now, the real thing
126-
a0 = Digest::MD5.digest( [ response.values_at(:username, :realm), @password ].join(':') )
127-
128-
a1 = [ a0, response.values_at(:nonce,:cnonce) ].join(':')
129-
a1 << ':' + response[:authzid] unless response[:authzid].nil?
130-
131-
a2 = "AUTHENTICATE:" + response[:'digest-uri']
132-
a2 << ":00000000000000000000000000000000" if response[:qop] and response[:qop] =~ /^auth-(?:conf|int)$/
133-
134-
response[:response] = Digest::MD5.hexdigest(
135-
[
136-
Digest::MD5.hexdigest(a1),
137-
response.values_at(:nonce, :nc, :cnonce, :qop),
138-
Digest::MD5.hexdigest(a2)
139-
].join(':')
140-
)
141-
142-
return response.keys.map {|key| qdval(key.to_s, response[key]) }.join(',')
231+
response[:response] = response_value(response)
232+
format_response(response)
143233
when STAGE_TWO
144234
@stage = STAGE_DONE
145-
# if at the second stage, return an empty string
146-
if challenge =~ /rspauth=/
147-
return ''
148-
else
149-
raise ResponseParseError, challenge
150-
end
235+
raise ResponseParseError, challenge unless challenge =~ /rspauth=/
236+
"" # if at the second stage, return an empty string
151237
else
152238
raise ResponseParseError, challenge
153239
end
240+
rescue => error
241+
@stage = error
242+
raise
154243
end
155244

156245
def done?; @stage == STAGE_DONE end
157246

158247
private
159248

249+
LWS = /[\r\n \t]*/n # less strict than RFC, more strict than '\s'
250+
TOKEN = /[^\x00-\x20\x7f()<>@,;:\\"\/\[\]?={}]+/n
251+
QUOTED_STR = /"(?: [\t\x20-\x7e&&[^"]] | \\[\x00-\x7f] )*"/nx
252+
LIST_DELIM = /(?:#{LWS} , )+ #{LWS}/nx
253+
AUTH_PARAM = /
254+
(#{TOKEN}) #{LWS} = #{LWS} (#{QUOTED_STR} | #{TOKEN}) #{LIST_DELIM}?
255+
/nx
256+
private_constant :LWS, :TOKEN, :QUOTED_STR, :LIST_DELIM, :AUTH_PARAM
257+
258+
def parse_challenge(challenge)
259+
sparams = Hash.new {|h, k| h[k] = [] }
260+
c = StringScanner.new(challenge)
261+
c.skip LIST_DELIM
262+
while c.scan AUTH_PARAM
263+
k, v = c[1], c[2]
264+
k = k.downcase
265+
if v =~ /\A"(.*)"\z/mn
266+
v = $1.gsub(/\\(.)/mn, '\1')
267+
v = split_quoted_list(v, challenge) if QUOTED_LISTABLE.include? k
268+
end
269+
sparams[k] << v
270+
end
271+
if !c.eos?
272+
raise DataFormatError, "Unparsable challenge: %p" % [challenge]
273+
elsif sparams.empty?
274+
raise DataFormatError, "Empty challenge: %p" % [challenge]
275+
end
276+
sparams
277+
end
278+
279+
def split_quoted_list(value, challenge)
280+
value.split(LIST_DELIM).reject(&:empty?).tap do
281+
_1.any? or raise DataFormatError, "Bad Challenge: %p" % [challenge]
282+
end
283+
end
284+
160285
def nc(nonce)
161286
if @nc.has_key? nonce
162287
@nc[nonce] = @nc[nonce] + 1
163288
else
164289
@nc[nonce] = 1
165290
end
166-
return @nc[nonce]
291+
end
292+
293+
def response_value(response)
294+
a1 = compute_a1(response)
295+
a2 = compute_a2(response)
296+
Digest::MD5.hexdigest(
297+
[
298+
Digest::MD5.hexdigest(a1),
299+
response.values_at(:nonce, :nc, :cnonce, :qop),
300+
Digest::MD5.hexdigest(a2)
301+
].join(":")
302+
)
303+
end
304+
305+
def compute_a0(response)
306+
Digest::MD5.digest(
307+
[ response.values_at(:username, :realm), password ].join(":")
308+
)
309+
end
310+
311+
def compute_a1(response)
312+
a0 = compute_a0(response)
313+
a1 = [ a0, response.values_at(:nonce, :cnonce) ].join(":")
314+
a1 << ":#{response[:authzid]}" unless response[:authzid].nil?
315+
a1
316+
end
317+
318+
def compute_a2(response)
319+
a2 = "AUTHENTICATE:#{response[:"digest-uri"]}"
320+
if response[:qop] and response[:qop] =~ /^auth-(?:conf|int)$/
321+
a2 << ":00000000000000000000000000000000"
322+
end
323+
a2
324+
end
325+
326+
def format_response(response)
327+
response.map {|k, v| qdval(k.to_s, v) }.join(",")
167328
end
168329

169330
# some responses need quoting

0 commit comments

Comments
 (0)