diff options
-rw-r--r-- | lib/net/imap.rb | 206 | ||||
-rw-r--r-- | lib/net/imap/authenticators.rb | 44 | ||||
-rw-r--r-- | lib/net/imap/authenticators/cram_md5.rb | 47 | ||||
-rw-r--r-- | lib/net/imap/authenticators/digest_md5.rb | 111 | ||||
-rw-r--r-- | lib/net/imap/authenticators/login.rb | 34 | ||||
-rw-r--r-- | lib/net/imap/authenticators/plain.rb | 19 |
6 files changed, 257 insertions, 204 deletions
diff --git a/lib/net/imap.rb b/lib/net/imap.rb index d3f2e25aeb..8a6b295b75 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -16,8 +16,6 @@ require "socket" require "monitor" -require "digest/md5" -require "strscan" require 'net/protocol' begin require "openssl" @@ -292,31 +290,6 @@ module Net @@max_flag_count = count end - # Adds an authenticator for Net::IMAP#authenticate. +auth_type+ - # is the type of authentication this authenticator supports - # (for instance, "LOGIN"). The +authenticator+ is an object - # which defines a process() method to handle authentication with - # the server. See Net::IMAP::LoginAuthenticator, - # Net::IMAP::CramMD5Authenticator, and Net::IMAP::DigestMD5Authenticator - # for examples. - # - # - # If +auth_type+ refers to an existing authenticator, it will be - # replaced by the new one. - def self.add_authenticator(auth_type, authenticator) - @@authenticators[auth_type] = authenticator - end - - # Builds an authenticator for Net::IMAP#authenticate. - def self.authenticator(auth_type, *args) - auth_type = auth_type.upcase - unless @@authenticators.has_key?(auth_type) - raise ArgumentError, - format('unknown auth type - "%s"', auth_type) - end - @@authenticators[auth_type].new(*args) - end - # The default port for IMAP connections, port 143 def self.default_port return PORT @@ -1124,7 +1097,6 @@ module Net SSL_PORT = 993 # :nodoc: @@debug = false - @@authenticators = {} @@max_flag_count = 10000 # :call-seq: @@ -3901,182 +3873,6 @@ module Net end end - # Authenticator for the "LOGIN" authentication type. See - # #authenticate(). - class LoginAuthenticator - def process(data) - case @state - when STATE_USER - @state = STATE_PASSWORD - return @user - when STATE_PASSWORD - return @password - end - end - - private - - STATE_USER = :USER - STATE_PASSWORD = :PASSWORD - - def initialize(user, password) - @user = user - @password = password - @state = STATE_USER - end - end - add_authenticator "LOGIN", LoginAuthenticator - - # Authenticator for the "PLAIN" authentication type. See - # #authenticate(). - class PlainAuthenticator - def process(data) - return "\0#{@user}\0#{@password}" - end - - private - - def initialize(user, password) - @user = user - @password = password - end - end - add_authenticator "PLAIN", PlainAuthenticator - - # Authenticator for the "CRAM-MD5" authentication type. See - # #authenticate(). - class CramMD5Authenticator - def process(challenge) - digest = hmac_md5(challenge, @password) - return @user + " " + digest - end - - private - - def initialize(user, password) - @user = user - @password = password - end - - def hmac_md5(text, key) - if key.length > 64 - key = Digest::MD5.digest(key) - end - - k_ipad = key + "\0" * (64 - key.length) - k_opad = key + "\0" * (64 - key.length) - for i in 0..63 - k_ipad[i] = (k_ipad[i].ord ^ 0x36).chr - k_opad[i] = (k_opad[i].ord ^ 0x5c).chr - end - - digest = Digest::MD5.digest(k_ipad + text) - - return Digest::MD5.hexdigest(k_opad + digest) - end - end - add_authenticator "CRAM-MD5", CramMD5Authenticator - - # Authenticator for the "DIGEST-MD5" authentication type. See - # #authenticate(). - class DigestMD5Authenticator - 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 - - raise DataFormatError, "Bad Challenge: '#{challenge}'" unless c.rest.size == 0 - raise Error, "Server does not support auth (qop = #{sparams['qop'].join(',')})" unless sparams['qop'].include?("auth") - - response = { - :nonce => sparams['nonce'], - :username => @user, - :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'], - } - - response[:authzid] = @authname unless @authname.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(',') - when STAGE_TWO - @stage = nil - # if at the second stage, return an empty string - if challenge =~ /rspauth=/ - return '' - else - raise ResponseParseError, challenge - end - else - raise ResponseParseError, challenge - end - end - - def initialize(user, password, authname = nil) - @user, @password, @authname = user, password, authname - @nc, @stage = {}, STAGE_ONE - end - - private - - STAGE_ONE = :stage_one - STAGE_TWO = :stage_two - - def nc(nonce) - if @nc.has_key? nonce - @nc[nonce] = @nc[nonce] + 1 - else - @nc[nonce] = 1 - end - return @nc[nonce] - 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.gsub!(/([\\"])/, "\\\1") - return '%s="%s"' % [k, v] - else - return '%s=%s' % [k, v] - end - end - end - add_authenticator "DIGEST-MD5", DigestMD5Authenticator - # Superclass of IMAP errors. class Error < StandardError end @@ -4130,3 +3926,5 @@ module Net end end end + +require_relative "imap/authenticators" diff --git a/lib/net/imap/authenticators.rb b/lib/net/imap/authenticators.rb new file mode 100644 index 0000000000..f86b77b09f --- /dev/null +++ b/lib/net/imap/authenticators.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# Registry for SASL authenticators used by Net::IMAP. +module Net::IMAP::Authenticators + + # Adds an authenticator for Net::IMAP#authenticate. +auth_type+ is the + # {SASL mechanism}[https://www.iana.org/assignments/sasl-mechanisms/sasl-mechanisms.xhtml] + # supported by +authenticator+ (for instance, "+LOGIN+"). The +authenticator+ + # is an object which defines a +#process+ method to handle authentication with + # the server. See Net::IMAP::LoginAuthenticator, + # Net::IMAP::CramMD5Authenticator, and Net::IMAP::DigestMD5Authenticator for + # examples. + # + # If +auth_type+ refers to an existing authenticator, it will be + # replaced by the new one. + def add_authenticator(auth_type, authenticator) + authenticators[auth_type] = authenticator + end + + # Builds an authenticator for Net::IMAP#authenticate. +args+ will be passed + # directly to the chosen authenticator's +#initialize+. + def authenticator(auth_type, *args) + auth_type = auth_type.upcase + unless authenticators.has_key?(auth_type) + raise ArgumentError, + format('unknown auth type - "%s"', auth_type) + end + authenticators[auth_type].new(*args) + end + + private + + def authenticators + @authenticators ||= {} + end + +end + +Net::IMAP.extend Net::IMAP::Authenticators + +require_relative "authenticators/login" +require_relative "authenticators/plain" +require_relative "authenticators/cram_md5" +require_relative "authenticators/digest_md5" diff --git a/lib/net/imap/authenticators/cram_md5.rb b/lib/net/imap/authenticators/cram_md5.rb new file mode 100644 index 0000000000..0bef638185 --- /dev/null +++ b/lib/net/imap/authenticators/cram_md5.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "digest/md5" + +# Authenticator for the "+CRAM-MD5+" SASL mechanism. See +# Net::IMAP#authenticate. +# +# == Deprecated +# +# +CRAM-MD5+ should be considered obsolete and insecure. It is included for +# backward compatibility with historic servers. +# {draft-ietf-sasl-crammd5-to-historic}[https://tools.ietf.org/html/draft-ietf-sasl-crammd5-to-historic-00.html] +# recommends using +SCRAM-*+ or +PLAIN+ protected by TLS instead. Additionally, +# RFC8314[https://tools.ietf.org/html/rfc8314] discourage the use of cleartext +# and recommends TLS version 1.2 or greater be used for all traffic. +class Net::IMAP::CramMD5Authenticator + def process(challenge) + digest = hmac_md5(challenge, @password) + return @user + " " + digest + end + + private + + def initialize(user, password) + @user = user + @password = password + end + + def hmac_md5(text, key) + if key.length > 64 + key = Digest::MD5.digest(key) + end + + k_ipad = key + "\0" * (64 - key.length) + k_opad = key + "\0" * (64 - key.length) + for i in 0..63 + k_ipad[i] = (k_ipad[i].ord ^ 0x36).chr + k_opad[i] = (k_opad[i].ord ^ 0x5c).chr + end + + digest = Digest::MD5.digest(k_ipad + text) + + return Digest::MD5.hexdigest(k_opad + digest) + end + + Net::IMAP.add_authenticator "PLAIN", self +end diff --git a/lib/net/imap/authenticators/digest_md5.rb b/lib/net/imap/authenticators/digest_md5.rb new file mode 100644 index 0000000000..a5f4b9093e --- /dev/null +++ b/lib/net/imap/authenticators/digest_md5.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "digest/md5" +require "strscan" + +# Net::IMAP authenticator for the "`DIGEST-MD5`" SASL mechanism type. See +# Net::IMAP#authenticate. +# +# == Deprecated +# +# "+DIGEST-MD5+" has been deprecated by +# {RFC6331}[https://tools.ietf.org/html/rfc6331] and should not be used. It +# is included for backward compatibility with historic servers. +class Net::IMAP::DigestMD5Authenticator + 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 + + raise DataFormatError, "Bad Challenge: '#{challenge}'" unless c.rest.size == 0 + raise Error, "Server does not support auth (qop = #{sparams['qop'].join(',')})" unless sparams['qop'].include?("auth") + + response = { + :nonce => sparams['nonce'], + :username => @user, + :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'], + } + + response[:authzid] = @authname unless @authname.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(',') + when STAGE_TWO + @stage = nil + # if at the second stage, return an empty string + if challenge =~ /rspauth=/ + return '' + else + raise ResponseParseError, challenge + end + else + raise ResponseParseError, challenge + end + end + + def initialize(user, password, authname = nil) + @user, @password, @authname = user, password, authname + @nc, @stage = {}, STAGE_ONE + end + + private + + STAGE_ONE = :stage_one + STAGE_TWO = :stage_two + + def nc(nonce) + if @nc.has_key? nonce + @nc[nonce] = @nc[nonce] + 1 + else + @nc[nonce] = 1 + end + return @nc[nonce] + 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.gsub!(/([\\"])/, "\\\1") + return '%s="%s"' % [k, v] + else + return '%s=%s' % [k, v] + end + end + + Net::IMAP.add_authenticator "DIGEST-MD5", self +end diff --git a/lib/net/imap/authenticators/login.rb b/lib/net/imap/authenticators/login.rb new file mode 100644 index 0000000000..8925d6de62 --- /dev/null +++ b/lib/net/imap/authenticators/login.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# Authenticator for the "+LOGIN+" SASL mechanism. See Net::IMAP#authenticate. +# +# == Deprecated +# +# The {SASL mechanisms +# registry}[https://www.iana.org/assignments/sasl-mechanisms/sasl-mechanisms.xhtml] +# marks "LOGIN" as obsoleted in favor of "PLAIN". See also +# {draft-murchison-sasl-login}[https://www.iana.org/go/draft-murchison-sasl-login]. +class Net::IMAP::LoginAuthenticator + def process(data) + case @state + when STATE_USER + @state = STATE_PASSWORD + return @user + when STATE_PASSWORD + return @password + end + end + + private + + STATE_USER = :USER + STATE_PASSWORD = :PASSWORD + + def initialize(user, password) + @user = user + @password = password + @state = STATE_USER + end + + Net::IMAP.add_authenticator "LOGIN", self +end diff --git a/lib/net/imap/authenticators/plain.rb b/lib/net/imap/authenticators/plain.rb new file mode 100644 index 0000000000..0829476c51 --- /dev/null +++ b/lib/net/imap/authenticators/plain.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# Authenticator for the "+PLAIN+" SASL mechanism. See Net::IMAP#authenticate. +# +# See RFC4616[https://tools.ietf.org/html/rfc4616] for the specification. +class Net::IMAP::PlainAuthenticator + def process(data) + return "\0#{@user}\0#{@password}" + end + + private + + def initialize(user, password) + @user = user + @password = password + end + + Net::IMAP.add_authenticator "PLAIN", self +end |