net-imap 0.3.7 → 0.4.5

Sign up to get free protection for your applications and to get access to all the features.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +46 -0
  3. data/.github/workflows/test.yml +5 -12
  4. data/.gitignore +2 -0
  5. data/Gemfile +3 -0
  6. data/README.md +15 -4
  7. data/Rakefile +0 -7
  8. data/docs/styles.css +0 -12
  9. data/lib/net/imap/authenticators.rb +26 -57
  10. data/lib/net/imap/command_data.rb +13 -6
  11. data/lib/net/imap/data_encoding.rb +14 -2
  12. data/lib/net/imap/deprecated_client_options.rb +139 -0
  13. data/lib/net/imap/errors.rb +20 -0
  14. data/lib/net/imap/fetch_data.rb +518 -0
  15. data/lib/net/imap/response_data.rb +116 -252
  16. data/lib/net/imap/response_parser/parser_utils.rb +240 -0
  17. data/lib/net/imap/response_parser.rb +1535 -1003
  18. data/lib/net/imap/sasl/anonymous_authenticator.rb +69 -0
  19. data/lib/net/imap/sasl/authentication_exchange.rb +107 -0
  20. data/lib/net/imap/sasl/authenticators.rb +118 -0
  21. data/lib/net/imap/sasl/client_adapter.rb +72 -0
  22. data/lib/net/imap/{authenticators/cram_md5.rb → sasl/cram_md5_authenticator.rb} +21 -11
  23. data/lib/net/imap/sasl/digest_md5_authenticator.rb +180 -0
  24. data/lib/net/imap/sasl/external_authenticator.rb +83 -0
  25. data/lib/net/imap/sasl/gs2_header.rb +80 -0
  26. data/lib/net/imap/{authenticators/login.rb → sasl/login_authenticator.rb} +25 -16
  27. data/lib/net/imap/sasl/oauthbearer_authenticator.rb +199 -0
  28. data/lib/net/imap/sasl/plain_authenticator.rb +101 -0
  29. data/lib/net/imap/sasl/protocol_adapters.rb +45 -0
  30. data/lib/net/imap/sasl/scram_algorithm.rb +58 -0
  31. data/lib/net/imap/sasl/scram_authenticator.rb +287 -0
  32. data/lib/net/imap/sasl/stringprep.rb +6 -66
  33. data/lib/net/imap/sasl/xoauth2_authenticator.rb +106 -0
  34. data/lib/net/imap/sasl.rb +144 -43
  35. data/lib/net/imap/sasl_adapter.rb +21 -0
  36. data/lib/net/imap/sequence_set.rb +67 -0
  37. data/lib/net/imap/stringprep/nameprep.rb +70 -0
  38. data/lib/net/imap/stringprep/saslprep.rb +69 -0
  39. data/lib/net/imap/stringprep/saslprep_tables.rb +96 -0
  40. data/lib/net/imap/stringprep/tables.rb +146 -0
  41. data/lib/net/imap/stringprep/trace.rb +85 -0
  42. data/lib/net/imap/stringprep.rb +159 -0
  43. data/lib/net/imap.rb +1055 -612
  44. data/net-imap.gemspec +4 -3
  45. data/rakelib/benchmarks.rake +91 -0
  46. data/rakelib/saslprep.rake +4 -4
  47. data/rakelib/string_prep_tables_generator.rb +82 -60
  48. metadata +31 -13
  49. data/benchmarks/stringprep.yml +0 -65
  50. data/benchmarks/table-regexps.yml +0 -39
  51. data/lib/net/imap/authenticators/digest_md5.rb +0 -115
  52. data/lib/net/imap/authenticators/plain.rb +0 -41
  53. data/lib/net/imap/authenticators/xoauth2.rb +0 -20
  54. data/lib/net/imap/sasl/saslprep.rb +0 -55
  55. data/lib/net/imap/sasl/saslprep_tables.rb +0 -98
  56. data/lib/net/imap/sasl/stringprep_tables.rb +0 -153
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ class IMAP < Protocol
5
+ module SASL
6
+
7
+ # Authenticator for the "+ANONYMOUS+" SASL mechanism, as specified by
8
+ # RFC-4505[https://tools.ietf.org/html/rfc4505]. See
9
+ # Net::IMAP#authenticate.
10
+ class AnonymousAuthenticator
11
+
12
+ # An optional token sent for the +ANONYMOUS+ mechanism., up to 255 UTF-8
13
+ # characters in length.
14
+ #
15
+ # If it contains an "@" sign, the message must be a valid email address
16
+ # (+addr-spec+ from RFC-2822[https://tools.ietf.org/html/rfc2822]).
17
+ # Email syntax is _not_ validated by AnonymousAuthenticator.
18
+ #
19
+ # Otherwise, it can be any UTF8 string which is permitted by the
20
+ # StringPrep::Trace profile.
21
+ attr_reader :anonymous_message
22
+
23
+ # :call-seq:
24
+ # new(anonymous_message = "", **) -> authenticator
25
+ # new(anonymous_message: "", **) -> authenticator
26
+ #
27
+ # Creates an Authenticator for the "+ANONYMOUS+" SASL mechanism, as
28
+ # specified in RFC-4505[https://tools.ietf.org/html/rfc4505]. To use
29
+ # this, see Net::IMAP#authenticate or your client's authentication
30
+ # method.
31
+ #
32
+ # ==== Parameters
33
+ #
34
+ # * _optional_ #anonymous_message — a message to send to the server.
35
+ #
36
+ # Any other keyword arguments are silently ignored.
37
+ def initialize(anon_msg = nil, anonymous_message: nil, **)
38
+ message = (anonymous_message || anon_msg || "").to_str
39
+ @anonymous_message = StringPrep::Trace.stringprep_trace message
40
+ if (size = @anonymous_message&.length)&.> 255
41
+ raise ArgumentError,
42
+ "anonymous_message is too long. (%d codepoints)" % [size]
43
+ end
44
+ @done = false
45
+ end
46
+
47
+ # :call-seq:
48
+ # initial_response? -> true
49
+ #
50
+ # +ANONYMOUS+ can send an initial client response.
51
+ def initial_response?; true end
52
+
53
+ # Returns #anonymous_message.
54
+ def process(_server_challenge_string)
55
+ anonymous_message
56
+ ensure
57
+ @done = true
58
+ end
59
+
60
+ # Returns true when the initial client response was sent.
61
+ #
62
+ # The authentication should not succeed unless this returns true, but it
63
+ # does *not* indicate success.
64
+ def done?; @done end
65
+
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ class IMAP
5
+ module SASL
6
+
7
+ # This API is *experimental*, and may change.
8
+ #
9
+ # TODO: catch exceptions in #process and send #cancel_response.
10
+ # TODO: raise an error if the command succeeds after being canceled.
11
+ # TODO: use with more clients, to verify the API can accommodate them.
12
+ #
13
+ # Create an AuthenticationExchange from a client adapter and a mechanism
14
+ # authenticator:
15
+ # def authenticate(mechanism, ...)
16
+ # authenticator = SASL.authenticator(mechanism, ...)
17
+ # SASL::AuthenticationExchange.new(
18
+ # sasl_adapter, mechanism, authenticator
19
+ # ).authenticate
20
+ # end
21
+ #
22
+ # private
23
+ #
24
+ # def sasl_adapter = MyClientAdapter.new(self, &method(:send_command))
25
+ #
26
+ # Or delegate creation of the authenticator to ::build:
27
+ # def authenticate(...)
28
+ # SASL::AuthenticationExchange.build(sasl_adapter, ...)
29
+ # .authenticate
30
+ # end
31
+ #
32
+ # As a convenience, ::authenticate combines ::build and #authenticate:
33
+ # def authenticate(...)
34
+ # SASL::AuthenticationExchange.authenticate(sasl_adapter, ...)
35
+ # end
36
+ #
37
+ # Likewise, ClientAdapter#authenticate delegates to #authenticate:
38
+ # def authenticate(...) = sasl_adapter.authenticate(...)
39
+ #
40
+ class AuthenticationExchange
41
+ # Convenience method for <tt>build(...).authenticate</tt>
42
+ def self.authenticate(...) build(...).authenticate end
43
+
44
+ # Use +registry+ to override the global Authenticators registry.
45
+ def self.build(client, mechanism, *args, sasl_ir: true, **kwargs, &block)
46
+ authenticator = SASL.authenticator(mechanism, *args, **kwargs, &block)
47
+ new(client, mechanism, authenticator, sasl_ir: sasl_ir)
48
+ end
49
+
50
+ attr_reader :mechanism, :authenticator
51
+
52
+ def initialize(client, mechanism, authenticator, sasl_ir: true)
53
+ @client = client
54
+ @mechanism = -mechanism.to_s.upcase.tr(?_, ?-)
55
+ @authenticator = authenticator
56
+ @sasl_ir = sasl_ir
57
+ @processed = false
58
+ end
59
+
60
+ # Call #authenticate to execute an authentication exchange for #client
61
+ # using #authenticator. Authentication failures will raise an
62
+ # exception. Any exceptions other than those in RESPONSE_ERRORS will
63
+ # drop the connection.
64
+ def authenticate
65
+ client.run_command(mechanism, initial_response) { process _1 }
66
+ .tap { raise AuthenticationIncomplete, _1 unless done? }
67
+ rescue *client.response_errors
68
+ raise # but don't drop the connection
69
+ rescue
70
+ client.drop_connection
71
+ raise
72
+ rescue Exception # rubocop:disable Lint/RescueException
73
+ client.drop_connection!
74
+ raise
75
+ end
76
+
77
+ def send_initial_response?
78
+ @sasl_ir &&
79
+ authenticator.respond_to?(:initial_response?) &&
80
+ authenticator.initial_response? &&
81
+ client.sasl_ir_capable? &&
82
+ client.auth_capable?(mechanism)
83
+ end
84
+
85
+ def done?
86
+ authenticator.respond_to?(:done?) ? authenticator.done? : @processed
87
+ end
88
+
89
+ private
90
+
91
+ attr_reader :client
92
+
93
+ def initial_response
94
+ return unless send_initial_response?
95
+ client.encode_ir authenticator.process nil
96
+ end
97
+
98
+ def process(challenge)
99
+ client.encode authenticator.process client.decode challenge
100
+ ensure
101
+ @processed = true
102
+ end
103
+
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net::IMAP::SASL
4
+
5
+ # Registry for SASL authenticators
6
+ #
7
+ # Registered authenticators must respond to +#new+ or +#call+ (e.g. a class or
8
+ # a proc), receiving any credentials and options and returning an
9
+ # authenticator instance. The returned object represents a single
10
+ # authentication exchange and <em>must not</em> be reused for multiple
11
+ # authentication attempts.
12
+ #
13
+ # An authenticator instance object must respond to +#process+, receiving the
14
+ # server's challenge and returning the client's response. Optionally, it may
15
+ # also respond to +#initial_response?+ and +#done?+. When
16
+ # +#initial_response?+ returns +true+, +#process+ may be called the first
17
+ # time with +nil+. When +#done?+ returns +false+, the exchange is incomplete
18
+ # and an exception should be raised if the exchange terminates prematurely.
19
+ #
20
+ # See the source for PlainAuthenticator, XOAuth2Authenticator, and
21
+ # ScramSHA1Authenticator for examples.
22
+ class Authenticators
23
+
24
+ # Create a new Authenticators registry.
25
+ #
26
+ # This class is usually not instantiated directly. Use SASL.authenticators
27
+ # to reuse the default global registry.
28
+ #
29
+ # When +use_defaults+ is +false+, the registry will start empty. When
30
+ # +use_deprecated+ is +false+, deprecated authenticators will not be
31
+ # included with the defaults.
32
+ def initialize(use_defaults: true, use_deprecated: true)
33
+ @authenticators = {}
34
+ return unless use_defaults
35
+ add_authenticator "Anonymous"
36
+ add_authenticator "External"
37
+ add_authenticator "OAuthBearer"
38
+ add_authenticator "Plain"
39
+ add_authenticator "Scram-SHA-1"
40
+ add_authenticator "Scram-SHA-256"
41
+ add_authenticator "XOAuth2"
42
+ return unless use_deprecated
43
+ add_authenticator "Login" # deprecated
44
+ add_authenticator "Cram-MD5" # deprecated
45
+ add_authenticator "Digest-MD5" # deprecated
46
+ end
47
+
48
+ # Returns the names of all registered SASL mechanisms.
49
+ def names; @authenticators.keys end
50
+
51
+ # :call-seq:
52
+ # add_authenticator(mechanism)
53
+ # add_authenticator(mechanism, authenticator_class)
54
+ # add_authenticator(mechanism, authenticator_proc)
55
+ #
56
+ # Registers an authenticator for #authenticator to use. +mechanism+ is the
57
+ # name of the
58
+ # {SASL mechanism}[https://www.iana.org/assignments/sasl-mechanisms/sasl-mechanisms.xhtml]
59
+ # implemented by +authenticator_class+ (for instance, <tt>"PLAIN"</tt>).
60
+ #
61
+ # If +mechanism+ refers to an existing authenticator,
62
+ # the old authenticator will be replaced.
63
+ #
64
+ # When only a single argument is given, the authenticator class will be
65
+ # lazily loaded from <tt>Net::IMAP::SASL::#{name}Authenticator</tt> (case is
66
+ # preserved and non-alphanumeric characters are removed..
67
+ def add_authenticator(name, authenticator = nil)
68
+ key = -name.to_s.upcase.tr(?_, ?-)
69
+ authenticator ||= begin
70
+ class_name = "#{name.gsub(/[^a-zA-Z0-9]/, "")}Authenticator".to_sym
71
+ auth_class = nil
72
+ ->(*creds, **props, &block) {
73
+ auth_class ||= Net::IMAP::SASL.const_get(class_name)
74
+ auth_class.new(*creds, **props, &block)
75
+ }
76
+ end
77
+ @authenticators[key] = authenticator
78
+ end
79
+
80
+ # Removes the authenticator registered for +name+
81
+ def remove_authenticator(name)
82
+ key = -name.to_s.upcase.tr(?_, ?-)
83
+ @authenticators.delete(key)
84
+ end
85
+
86
+ def mechanism?(name)
87
+ key = -name.to_s.upcase.tr(?_, ?-)
88
+ @authenticators.key?(key)
89
+ end
90
+
91
+ # :call-seq:
92
+ # authenticator(mechanism, ...) -> auth_session
93
+ #
94
+ # Builds an authenticator instance using the authenticator registered to
95
+ # +mechanism+. The returned object represents a single authentication
96
+ # exchange and <em>must not</em> be reused for multiple authentication
97
+ # attempts.
98
+ #
99
+ # All arguments (except +mechanism+) are forwarded to the registered
100
+ # authenticator's +#new+ or +#call+ method. Each authenticator must
101
+ # document its own arguments.
102
+ #
103
+ # [Note]
104
+ # This method is intended for internal use by connection protocol code
105
+ # only. Protocol client users should see refer to their client's
106
+ # documentation, e.g. Net::IMAP#authenticate.
107
+ def authenticator(mechanism, ...)
108
+ key = -mechanism.to_s.upcase.tr(?_, ?-)
109
+ auth = @authenticators.fetch(key) do
110
+ raise ArgumentError, 'unknown auth type - "%s"' % key
111
+ end
112
+ auth.respond_to?(:new) ? auth.new(...) : auth.call(...)
113
+ end
114
+ alias new authenticator
115
+
116
+ end
117
+
118
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ class IMAP
5
+ module SASL
6
+
7
+ # This API is *experimental*, and may change.
8
+ #
9
+ # TODO: use with more clients, to verify the API can accommodate them.
10
+ #
11
+ # An abstract base class for implementing a SASL authentication exchange.
12
+ # Different clients will each have their own adapter subclass, overridden
13
+ # to match their needs.
14
+ #
15
+ # Although the default implementations _may_ be sufficient, subclasses
16
+ # will probably need to override some methods. Additionally, subclasses
17
+ # may need to include a protocol adapter mixin, if the default
18
+ # ProtocolAdapters::Generic isn't sufficient.
19
+ class ClientAdapter
20
+ include ProtocolAdapters::Generic
21
+
22
+ attr_reader :client, :command_proc
23
+
24
+ # +command_proc+ can used to avoid exposing private methods on #client.
25
+ # It should run a command with the arguments sent to it, yield each
26
+ # continuation payload, respond to the server with the result of each
27
+ # yield, and return the result. Non-successful results *MUST* raise an
28
+ # exception. Exceptions in the block *MUST* cause the command to fail.
29
+ #
30
+ # Subclasses that override #run_command may use #command_proc for
31
+ # other purposes.
32
+ def initialize(client, &command_proc)
33
+ @client, @command_proc = client, command_proc
34
+ end
35
+
36
+ # Delegates to AuthenticationExchange.authenticate.
37
+ def authenticate(...) AuthenticationExchange.authenticate(self, ...) end
38
+
39
+ # Do the protocol and server both support an initial response?
40
+ def sasl_ir_capable?; client.sasl_ir_capable? end
41
+
42
+ # Does the server advertise support for the mechanism?
43
+ def auth_capable?(mechanism); client.auth_capable?(mechanism) end
44
+
45
+ # Runs the authenticate command with +mechanism+ and +initial_response+.
46
+ # When +initial_response+ is nil, an initial response must NOT be sent.
47
+ #
48
+ # Yields each continuation payload, responds to the server with the
49
+ # result of each yield, and returns the result. Non-successful results
50
+ # *MUST* raise an exception. Exceptions in the block *MUST* cause the
51
+ # command to fail.
52
+ #
53
+ # Subclasses that override this may use #command_proc differently.
54
+ def run_command(mechanism, initial_response = nil, &block)
55
+ command_proc or raise Error, "initialize with block or override"
56
+ args = [command_name, mechanism, initial_response].compact
57
+ command_proc.call(*args, &block)
58
+ end
59
+
60
+ # Returns an array of server responses errors raised by run_command.
61
+ # Exceptions in this array won't drop the connection.
62
+ def response_errors; [] end
63
+
64
+ # Drop the connection gracefully.
65
+ def drop_connection; client.drop_connection end
66
+
67
+ # Drop the connection abruptly.
68
+ def drop_connection!; client.drop_connection! end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -13,22 +13,33 @@
13
13
  # Additionally, RFC8314[https://tools.ietf.org/html/rfc8314] discourage the use
14
14
  # of cleartext and recommends TLS version 1.2 or greater be used for all
15
15
  # traffic. With TLS +CRAM-MD5+ is okay, but so is +PLAIN+
16
- class Net::IMAP::CramMD5Authenticator
16
+ class Net::IMAP::SASL::CramMD5Authenticator
17
+ def initialize(user = nil, pass = nil,
18
+ authcid: nil, username: nil,
19
+ password: nil, secret: nil,
20
+ warn_deprecation: true,
21
+ **)
22
+ if warn_deprecation
23
+ warn "WARNING: CRAM-MD5 mechanism is deprecated." # TODO: recommend SCRAM
24
+ end
25
+ require "digest/md5"
26
+ @user = authcid || username || user
27
+ @password = password || secret || pass
28
+ @done = false
29
+ end
30
+
31
+ def initial_response?; false end
32
+
17
33
  def process(challenge)
18
34
  digest = hmac_md5(challenge, @password)
19
35
  return @user + " " + digest
36
+ ensure
37
+ @done = true
20
38
  end
21
39
 
22
- private
40
+ def done?; @done end
23
41
 
24
- def initialize(user, password, warn_deprecation: true, **_ignored)
25
- if warn_deprecation
26
- warn "WARNING: CRAM-MD5 mechanism is deprecated." # TODO: recommend SCRAM
27
- end
28
- require "digest/md5"
29
- @user = user
30
- @password = password
31
- end
42
+ private
32
43
 
33
44
  def hmac_md5(text, key)
34
45
  if key.length > 64
@@ -47,5 +58,4 @@ class Net::IMAP::CramMD5Authenticator
47
58
  return Digest::MD5.hexdigest(k_opad + digest)
48
59
  end
49
60
 
50
- Net::IMAP.add_authenticator "CRAM-MD5", self
51
61
  end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Net::IMAP authenticator for the "`DIGEST-MD5`" SASL mechanism type, specified
4
+ # in RFC-2831[https://tools.ietf.org/html/rfc2831]. See Net::IMAP#authenticate.
5
+ #
6
+ # == Deprecated
7
+ #
8
+ # "+DIGEST-MD5+" has been deprecated by
9
+ # RFC-6331[https://tools.ietf.org/html/rfc6331] and should not be relied on for
10
+ # security. It is included for compatibility with existing servers.
11
+ class Net::IMAP::SASL::DigestMD5Authenticator
12
+ STAGE_ONE = :stage_one
13
+ STAGE_TWO = :stage_two
14
+ STAGE_DONE = :stage_done
15
+ private_constant :STAGE_ONE, :STAGE_TWO, :STAGE_DONE
16
+
17
+ # Authentication identity: the identity that matches the #password.
18
+ #
19
+ # RFC-2831[https://tools.ietf.org/html/rfc2831] uses the term +username+.
20
+ # "Authentication identity" is the generic term used by
21
+ # RFC-4422[https://tools.ietf.org/html/rfc4422].
22
+ # RFC-4616[https://tools.ietf.org/html/rfc4616] and many later RFCs abbreviate
23
+ # this to +authcid+.
24
+ attr_reader :username
25
+ alias authcid username
26
+
27
+ # A password or passphrase that matches the #username.
28
+ #
29
+ # The +password+ will be used to create the response digest.
30
+ attr_reader :password
31
+
32
+ # Authorization identity: an identity to act as or on behalf of. The identity
33
+ # form is application protocol specific. If not provided or left blank, the
34
+ # server derives an authorization identity from the authentication identity.
35
+ # The server is responsible for verifying the client's credentials and
36
+ # verifying that the identity it associates with the client's authentication
37
+ # identity is allowed to act as (or on behalf of) the authorization identity.
38
+ #
39
+ # For example, an administrator or superuser might take on another role:
40
+ #
41
+ # imap.authenticate "DIGEST-MD5", "root", ->{passwd}, authzid: "user"
42
+ #
43
+ attr_reader :authzid
44
+
45
+ # :call-seq:
46
+ # new(username, password, authzid = nil, **options) -> authenticator
47
+ # new(username:, password:, authzid: nil, **options) -> authenticator
48
+ # new(authcid:, password:, authzid: nil, **options) -> authenticator
49
+ #
50
+ # Creates an Authenticator for the "+DIGEST-MD5+" SASL mechanism.
51
+ #
52
+ # Called by Net::IMAP#authenticate and similar methods on other clients.
53
+ #
54
+ # ==== Parameters
55
+ #
56
+ # * #authcid ― Authentication identity that is associated with #password.
57
+ #
58
+ # #username ― An alias for +authcid+.
59
+ #
60
+ # * #password ― A password or passphrase associated with this #authcid.
61
+ #
62
+ # * _optional_ #authzid ― Authorization identity to act as or on behalf of.
63
+ #
64
+ # When +authzid+ is not set, the server should derive the authorization
65
+ # identity from the authentication identity.
66
+ #
67
+ # * _optional_ +warn_deprecation+ — Set to +false+ to silence the warning.
68
+ #
69
+ # Any other keyword arguments are silently ignored.
70
+ def initialize(user = nil, pass = nil, authz = nil,
71
+ username: nil, password: nil, authzid: nil,
72
+ authcid: nil, secret: nil,
73
+ warn_deprecation: true, **)
74
+ username = authcid || username || user or
75
+ raise ArgumentError, "missing username (authcid)"
76
+ password ||= secret || pass or raise ArgumentError, "missing password"
77
+ authzid ||= authz
78
+ if warn_deprecation
79
+ warn "WARNING: DIGEST-MD5 SASL mechanism was deprecated by RFC6331."
80
+ # TODO: recommend SCRAM instead.
81
+ end
82
+ require "digest/md5"
83
+ require "strscan"
84
+ @username, @password, @authzid = username, password, authzid
85
+ @nc, @stage = {}, STAGE_ONE
86
+ end
87
+
88
+ def initial_response?; false end
89
+
90
+ # Responds to server challenge in two stages.
91
+ def process(challenge)
92
+ case @stage
93
+ when STAGE_ONE
94
+ @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
107
+
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")
110
+
111
+ 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'],
121
+ }
122
+
123
+ response[:authzid] = @authzid unless @authzid.nil?
124
+
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(',')
143
+ when STAGE_TWO
144
+ @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
151
+ else
152
+ raise ResponseParseError, challenge
153
+ end
154
+ end
155
+
156
+ def done?; @stage == STAGE_DONE end
157
+
158
+ private
159
+
160
+ def nc(nonce)
161
+ if @nc.has_key? nonce
162
+ @nc[nonce] = @nc[nonce] + 1
163
+ else
164
+ @nc[nonce] = 1
165
+ end
166
+ return @nc[nonce]
167
+ end
168
+
169
+ # some responses need quoting
170
+ def qdval(k, v)
171
+ return if k.nil? or v.nil?
172
+ if %w"username authzid realm nonce cnonce digest-uri qop".include? k
173
+ v = v.gsub(/([\\"])/, "\\\1")
174
+ return '%s="%s"' % [k, v]
175
+ else
176
+ return '%s=%s' % [k, v]
177
+ end
178
+ end
179
+
180
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ class IMAP < Protocol
5
+ module SASL
6
+
7
+ # Authenticator for the "+EXTERNAL+" SASL mechanism, as specified by
8
+ # RFC-4422[https://tools.ietf.org/html/rfc4422]. See
9
+ # Net::IMAP#authenticate.
10
+ #
11
+ # The EXTERNAL mechanism requests that the server use client credentials
12
+ # established external to SASL, for example by TLS certificate or IPsec.
13
+ class ExternalAuthenticator
14
+
15
+ # Authorization identity: an identity to act as or on behalf of. The
16
+ # identity form is application protocol specific. If not provided or
17
+ # left blank, the server derives an authorization identity from the
18
+ # authentication identity. The server is responsible for verifying the
19
+ # client's credentials and verifying that the identity it associates
20
+ # with the client's authentication identity is allowed to act as (or on
21
+ # behalf of) the authorization identity.
22
+ #
23
+ # For example, an administrator or superuser might take on another role:
24
+ #
25
+ # imap.authenticate "PLAIN", "root", passwd, authzid: "user"
26
+ #
27
+ attr_reader :authzid
28
+ alias username authzid
29
+
30
+ # :call-seq:
31
+ # new(authzid: nil, **) -> authenticator
32
+ # new(username: nil, **) -> authenticator
33
+ # new(username = nil, **) -> authenticator
34
+ #
35
+ # Creates an Authenticator for the "+EXTERNAL+" SASL mechanism, as
36
+ # specified in RFC-4422[https://tools.ietf.org/html/rfc4422]. To use
37
+ # this, see Net::IMAP#authenticate or your client's authentication
38
+ # method.
39
+ #
40
+ # ==== Parameters
41
+ #
42
+ # * _optional_ #authzid ― Authorization identity to act as or on behalf of.
43
+ #
44
+ # _optional_ #username ― An alias for #authzid.
45
+ #
46
+ # Note that, unlike some other authenticators, +username+ sets the
47
+ # _authorization_ identity and not the _authentication_ identity. The
48
+ # authentication identity is established for the client by the
49
+ # external credentials.
50
+ #
51
+ # Any other keyword parameters are quietly ignored.
52
+ def initialize(user = nil, authzid: nil, username: nil, **)
53
+ authzid ||= username || user
54
+ @authzid = authzid&.to_str&.encode "UTF-8"
55
+ if @authzid&.match?(/\u0000/u) # also validates UTF8 encoding
56
+ raise ArgumentError, "contains NULL"
57
+ end
58
+ @done = false
59
+ end
60
+
61
+ # :call-seq:
62
+ # initial_response? -> true
63
+ #
64
+ # +EXTERNAL+ can send an initial client response.
65
+ def initial_response?; true end
66
+
67
+ # Returns #authzid, or an empty string if there is no authzid.
68
+ def process(_)
69
+ authzid || ""
70
+ ensure
71
+ @done = true
72
+ end
73
+
74
+ # Returns true when the initial client response was sent.
75
+ #
76
+ # The authentication should not succeed unless this returns true, but it
77
+ # does *not* indicate success.
78
+ def done?; @done end
79
+
80
+ end
81
+ end
82
+ end
83
+ end