net-imap 0.3.7 → 0.4.7

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 +1696 -1196
  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 +1061 -612
  44. data/net-imap.gemspec +5 -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 +33 -14
  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,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "securerandom"
5
+
6
+ require_relative "gs2_header"
7
+ require_relative "scram_algorithm"
8
+
9
+ module Net
10
+ class IMAP
11
+ module SASL
12
+
13
+ # Abstract base class for the "+SCRAM-*+" family of SASL mechanisms,
14
+ # defined in RFC5802[https://tools.ietf.org/html/rfc5802]. Use via
15
+ # Net::IMAP#authenticate.
16
+ #
17
+ # Directly supported:
18
+ # * +SCRAM-SHA-1+ --- ScramSHA1Authenticator
19
+ # * +SCRAM-SHA-256+ --- ScramSHA256Authenticator
20
+ #
21
+ # New +SCRAM-*+ mechanisms can easily be added for any hash algorithm
22
+ # supported by
23
+ # OpenSSL::Digest[https://ruby.github.io/openssl/OpenSSL/Digest.html].
24
+ # Subclasses need only set an appropriate +DIGEST_NAME+ constant.
25
+ #
26
+ # === SCRAM algorithm
27
+ #
28
+ # See the documentation and method definitions on ScramAlgorithm for an
29
+ # overview of the algorithm. The different mechanisms differ only by
30
+ # which hash function that is used (or by support for channel binding with
31
+ # +-PLUS+).
32
+ #
33
+ # See also the methods on GS2Header.
34
+ #
35
+ # ==== Server messages
36
+ #
37
+ # As server messages are received, they are validated and loaded into
38
+ # the various attributes, e.g: #snonce, #salt, #iterations, #verifier,
39
+ # #server_error, etc.
40
+ #
41
+ # Unlike many other SASL mechanisms, the +SCRAM-*+ family supports mutual
42
+ # authentication and can return server error data in the server messages.
43
+ # If #process raises an Error for the server-final-message, then
44
+ # server_error may contain error details.
45
+ #
46
+ # === TLS Channel binding
47
+ #
48
+ # <em>The <tt>SCRAM-*-PLUS</tt> mechanisms and channel binding are not
49
+ # supported yet.</em>
50
+ #
51
+ # === Caching SCRAM secrets
52
+ #
53
+ # <em>Caching of salted_password, client_key, stored_key, and server_key
54
+ # is not supported yet.</em>
55
+ #
56
+ class ScramAuthenticator
57
+ include GS2Header
58
+ include ScramAlgorithm
59
+
60
+ # :call-seq:
61
+ # new(username, password, **options) -> auth_ctx
62
+ # new(username:, password:, **options) -> auth_ctx
63
+ # new(authcid:, password:, **options) -> auth_ctx
64
+ #
65
+ # Creates an authenticator for one of the "+SCRAM-*+" SASL mechanisms.
66
+ # Each subclass defines #digest to match a specific mechanism.
67
+ #
68
+ # Called by Net::IMAP#authenticate and similar methods on other clients.
69
+ #
70
+ # === Parameters
71
+ #
72
+ # * #authcid ― Identity whose #password is used.
73
+ #
74
+ # #username - An alias for #authcid.
75
+ # * #password ― Password or passphrase associated with this #username.
76
+ # * _optional_ #authzid ― Alternate identity to act as or on behalf of.
77
+ # * _optional_ #min_iterations - Overrides the default value (4096).
78
+ #
79
+ # Any other keyword parameters are quietly ignored.
80
+ def initialize(username_arg = nil, password_arg = nil,
81
+ authcid: nil, username: nil,
82
+ authzid: nil,
83
+ password: nil, secret: nil,
84
+ min_iterations: 4096, # see both RFC5802 and RFC7677
85
+ cnonce: nil, # must only be set in tests
86
+ **options)
87
+ @username = username || username_arg || authcid or
88
+ raise ArgumentError, "missing username (authcid)"
89
+ @password = password || secret || password_arg or
90
+ raise ArgumentError, "missing password"
91
+ @authzid = authzid
92
+
93
+ @min_iterations = Integer min_iterations
94
+ @min_iterations.positive? or
95
+ raise ArgumentError, "min_iterations must be positive"
96
+
97
+ @cnonce = cnonce || SecureRandom.base64(32)
98
+ end
99
+
100
+ # Authentication identity: the identity that matches the #password.
101
+ #
102
+ # RFC-2831[https://tools.ietf.org/html/rfc2831] uses the term +username+.
103
+ # "Authentication identity" is the generic term used by
104
+ # RFC-4422[https://tools.ietf.org/html/rfc4422].
105
+ # RFC-4616[https://tools.ietf.org/html/rfc4616] and many later RFCs abbreviate
106
+ # this to +authcid+.
107
+ attr_reader :username
108
+ alias authcid username
109
+
110
+ # A password or passphrase that matches the #username.
111
+ attr_reader :password
112
+ alias secret password
113
+
114
+ # Authorization identity: an identity to act as or on behalf of. The
115
+ # identity form is application protocol specific. If not provided or
116
+ # left blank, the server derives an authorization identity from the
117
+ # authentication identity. For example, an administrator or superuser
118
+ # might take on another role:
119
+ #
120
+ # imap.authenticate "SCRAM-SHA-256", "root", passwd, authzid: "user"
121
+ #
122
+ # The server is responsible for verifying the client's credentials and
123
+ # verifying that the identity it associates with the client's
124
+ # authentication identity is allowed to act as (or on behalf of) the
125
+ # authorization identity.
126
+ attr_reader :authzid
127
+
128
+ # The minimal allowed iteration count. Lower #iterations will raise an
129
+ # Error.
130
+ attr_reader :min_iterations
131
+
132
+ # The client nonce, generated by SecureRandom
133
+ attr_reader :cnonce
134
+
135
+ # The server nonce, which must start with #cnonce
136
+ attr_reader :snonce
137
+
138
+ # The salt used by the server for this user
139
+ attr_reader :salt
140
+
141
+ # The iteration count for the selected hash function and user
142
+ attr_reader :iterations
143
+
144
+ # An error reported by the server during the \SASL exchange.
145
+ #
146
+ # Does not include errors reported by the protocol, e.g.
147
+ # Net::IMAP::NoResponseError.
148
+ attr_reader :server_error
149
+
150
+ # Returns a new OpenSSL::Digest object, set to the appropriate hash
151
+ # function for the chosen mechanism.
152
+ #
153
+ # <em>The class's +DIGEST_NAME+ constant must be set to the name of an
154
+ # algorithm supported by OpenSSL::Digest.</em>
155
+ def digest; OpenSSL::Digest.new self.class::DIGEST_NAME end
156
+
157
+ # See {RFC5802 §7}[https://www.rfc-editor.org/rfc/rfc5802#section-7]
158
+ # +client-first-message+.
159
+ def initial_client_response
160
+ "#{gs2_header}#{client_first_message_bare}"
161
+ end
162
+
163
+ # responds to the server's challenges
164
+ def process(challenge)
165
+ case (@state ||= :initial_client_response)
166
+ when :initial_client_response
167
+ initial_client_response.tap { @state = :server_first_message }
168
+ when :server_first_message
169
+ recv_server_first_message challenge
170
+ final_message_with_proof.tap { @state = :server_final_message }
171
+ when :server_final_message
172
+ recv_server_final_message challenge
173
+ "".tap { @state = :done }
174
+ else
175
+ raise Error, "server sent after complete, %p" % [challenge]
176
+ end
177
+ rescue Exception => ex
178
+ @state = ex
179
+ raise
180
+ end
181
+
182
+ # Is the authentication exchange complete?
183
+ #
184
+ # If false, another server continuation is required.
185
+ def done?; @state == :done end
186
+
187
+ private
188
+
189
+ # Need to store this for auth_message
190
+ attr_reader :server_first_message
191
+
192
+ def format_message(hash) hash.map { _1.join("=") }.join(",") end
193
+
194
+ def recv_server_first_message(server_first_message)
195
+ @server_first_message = server_first_message
196
+ sparams = parse_challenge server_first_message
197
+ @snonce = sparams["r"] or
198
+ raise Error, "server did not send nonce"
199
+ @salt = sparams["s"]&.unpack1("m") or
200
+ raise Error, "server did not send salt"
201
+ @iterations = sparams["i"]&.then {|i| Integer i } or
202
+ raise Error, "server did not send iteration count"
203
+ min_iterations <= iterations or
204
+ raise Error, "too few iterations: %d" % [iterations]
205
+ mext = sparams["m"] and
206
+ raise Error, "mandatory extension: %p" % [mext]
207
+ snonce.start_with? cnonce or
208
+ raise Error, "invalid server nonce"
209
+ end
210
+
211
+ def recv_server_final_message(server_final_message)
212
+ sparams = parse_challenge server_final_message
213
+ @server_error = sparams["e"] and
214
+ raise Error, "server error: %s" % [server_error]
215
+ verifier = sparams["v"].unpack1("m") or
216
+ raise Error, "server did not send verifier"
217
+ verifier == server_signature or
218
+ raise Error, "server verify failed: %p != %p" % [
219
+ server_signature, verifier
220
+ ]
221
+ end
222
+
223
+ # See {RFC5802 §7}[https://www.rfc-editor.org/rfc/rfc5802#section-7]
224
+ # +client-first-message-bare+.
225
+ def client_first_message_bare
226
+ @client_first_message_bare ||=
227
+ format_message(n: gs2_saslname_encode(SASL.saslprep(username)),
228
+ r: cnonce)
229
+ end
230
+
231
+ # See {RFC5802 §7}[https://www.rfc-editor.org/rfc/rfc5802#section-7]
232
+ # +client-final-message+.
233
+ def final_message_with_proof
234
+ proof = [client_proof].pack("m0")
235
+ "#{client_final_message_without_proof},p=#{proof}"
236
+ end
237
+
238
+ # See {RFC5802 §7}[https://www.rfc-editor.org/rfc/rfc5802#section-7]
239
+ # +client-final-message-without-proof+.
240
+ def client_final_message_without_proof
241
+ @client_final_message_without_proof ||=
242
+ format_message(c: [cbind_input].pack("m0"), # channel-binding
243
+ r: snonce) # nonce
244
+ end
245
+
246
+ # See {RFC5802 §7}[https://www.rfc-editor.org/rfc/rfc5802#section-7]
247
+ # +cbind-input+.
248
+ #
249
+ # >>>
250
+ # *TODO:* implement channel binding, appending +cbind-data+ here.
251
+ alias cbind_input gs2_header
252
+
253
+ # RFC5802 specifies "that the order of attributes in client or server
254
+ # messages is fixed, with the exception of extension attributes", but
255
+ # this parses it simply as a hash, without respect to order. Note that
256
+ # repeated keys (violating the spec) will use the last value.
257
+ def parse_challenge(challenge)
258
+ challenge.split(/,/).to_h {|pair| pair.split(/=/, 2) }
259
+ rescue ArgumentError
260
+ raise Error, "unparsable challenge: %p" % [challenge]
261
+ end
262
+
263
+ end
264
+
265
+ # Authenticator for the "+SCRAM-SHA-1+" SASL mechanism, defined in
266
+ # RFC5802[https://tools.ietf.org/html/rfc5802].
267
+ #
268
+ # Uses the "SHA-1" digest algorithm from OpenSSL::Digest.
269
+ #
270
+ # See ScramAuthenticator.
271
+ class ScramSHA1Authenticator < ScramAuthenticator
272
+ DIGEST_NAME = "SHA1"
273
+ end
274
+
275
+ # Authenticator for the "+SCRAM-SHA-256+" SASL mechanism, defined in
276
+ # RFC7677[https://tools.ietf.org/html/rfc7677].
277
+ #
278
+ # Uses the "SHA-256" digest algorithm from OpenSSL::Digest.
279
+ #
280
+ # See ScramAuthenticator.
281
+ class ScramSHA256Authenticator < ScramAuthenticator
282
+ DIGEST_NAME = "SHA256"
283
+ end
284
+
285
+ end
286
+ end
287
+ end
@@ -1,72 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "stringprep_tables"
4
-
5
3
  module Net::IMAP::SASL
6
4
 
7
- # Regexps and utility methods for implementing stringprep profiles. The
8
- # \StringPrep algorithm is defined by
9
- # {RFC-3454}[https://www.rfc-editor.org/rfc/rfc3454.html]. Each
10
- # codepoint table defined in the RFC-3454 appendices is matched by a Regexp
11
- # defined in this module.
12
- #--
13
- # TODO: generic StringPrep mapping (not needed for SASLprep implementation)
14
- #++
15
- module StringPrep
16
-
17
- # Returns a Regexp matching the given +table+ name.
18
- def self.[](table)
19
- TABLE_REGEXPS.fetch(table)
20
- end
21
-
22
- module_function
23
-
24
- # Checks +string+ for any codepoint in +tables+. Raises a
25
- # ProhibitedCodepoint describing the first matching table.
26
- #
27
- # Also checks bidirectional characters, when <tt>bidi: true</tt>, which may
28
- # raise a BidiStringError.
29
- #
30
- # +profile+ is an optional string which will be added to any exception that
31
- # is raised (it does not affect behavior).
32
- def check_prohibited!(string, *tables, bidi: false, profile: nil)
33
- tables = TABLE_TITLES.keys.grep(/^C/) if tables.empty?
34
- tables |= %w[C.8] if bidi
35
- table = tables.find {|t| TABLE_REGEXPS[t].match?(string) }
36
- raise ProhibitedCodepoint.new(
37
- table, string: string, profile: nil
38
- ) if table
39
- check_bidi!(string, profile: profile) if bidi
40
- end
41
-
42
- # Checks that +string+ obeys all of the "Bidirectional Characters"
43
- # requirements in RFC-3454, §6:
44
- #
45
- # * The characters in \StringPrep\[\"C.8\"] MUST be prohibited
46
- # * If a string contains any RandALCat character, the string MUST NOT
47
- # contain any LCat character.
48
- # * If a string contains any RandALCat character, a RandALCat
49
- # character MUST be the first character of the string, and a
50
- # RandALCat character MUST be the last character of the string.
51
- #
52
- # This is usually combined with #check_prohibited!, so table "C.8" is only
53
- # checked when <tt>c_8: true</tt>.
54
- #
55
- # Raises either ProhibitedCodepoint or BidiStringError unless all
56
- # requirements are met. +profile+ is an optional string which will be
57
- # added to any exception that is raised (it does not affect behavior).
58
- def check_bidi!(string, c_8: false, profile: nil)
59
- check_prohibited!(string, "C.8", profile: profile) if c_8
60
- if BIDI_FAILS_REQ2.match?(string)
61
- raise BidiStringError.new(
62
- BIDI_DESC_REQ2, string: string, profile: profile,
63
- )
64
- elsif BIDI_FAILS_REQ3.match?(string)
65
- raise BidiStringError.new(
66
- BIDI_DESC_REQ3, string: string, profile: profile,
67
- )
68
- end
69
- end
5
+ # Alias for Net::IMAP::StringPrep::SASLprep.
6
+ SASLprep = Net::IMAP::StringPrep::SASLprep
7
+ StringPrep = Net::IMAP::StringPrep # :nodoc:
8
+ BidiStringError = Net::IMAP::StringPrep::BidiStringError # :nodoc:
9
+ ProhibitedCodepoint = Net::IMAP::StringPrep::ProhibitedCodepoint # :nodoc:
10
+ StringPrepError = Net::IMAP::StringPrep::StringPrepError # :nodoc:
70
11
 
71
- end
72
12
  end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Authenticator for the "+XOAUTH2+" SASL mechanism. This mechanism was
4
+ # originally created for GMail and widely adopted by hosted email providers.
5
+ # +XOAUTH2+ has been documented by
6
+ # Google[https://developers.google.com/gmail/imap/xoauth2-protocol] and
7
+ # Microsoft[https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth].
8
+ #
9
+ # This mechanism requires an OAuth2 access token which has been authorized
10
+ # with the appropriate OAuth2 scopes to access the user's services. Most of
11
+ # these scopes are not standardized---consult each service provider's
12
+ # documentation for their scopes.
13
+ #
14
+ # Although this mechanism was never standardized and has been obsoleted by
15
+ # "+OAUTHBEARER+", it is still very widely supported.
16
+ #
17
+ # See Net::IMAP::SASL::OAuthBearerAuthenticator.
18
+ class Net::IMAP::SASL::XOAuth2Authenticator
19
+
20
+ # It is unclear from {Google's original XOAUTH2
21
+ # documentation}[https://developers.google.com/gmail/imap/xoauth2-protocol],
22
+ # whether "User" refers to the authentication identity (+authcid+) or the
23
+ # authorization identity (+authzid+). The authentication identity is
24
+ # established for the client by the OAuth token, so it seems that +username+
25
+ # must be the authorization identity.
26
+ #
27
+ # {Microsoft's documentation for shared
28
+ # mailboxes}[https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth#sasl-xoauth2-authentication-for-shared-mailboxes-in-office-365]
29
+ # _clearly_ indicates that the Office 365 server interprets it as the
30
+ # authorization identity.
31
+ #
32
+ # Although they _should_ validate that the token has been authorized to access
33
+ # the service for +username+, _some_ servers appear to ignore this field,
34
+ # relying only the identity and scope authorized by the token.
35
+ attr_reader :username
36
+
37
+ # Note that, unlike most other authenticators, #username is an alias for the
38
+ # authorization identity and not the authentication identity. The
39
+ # authenticated identity is established for the client by the #oauth2_token.
40
+ alias authzid username
41
+
42
+ # An OAuth2 access token which has been authorized with the appropriate OAuth2
43
+ # scopes to use the service for #username.
44
+ attr_reader :oauth2_token
45
+ alias secret oauth2_token
46
+
47
+ # :call-seq:
48
+ # new(username, oauth2_token, **) -> authenticator
49
+ # new(username:, oauth2_token:, **) -> authenticator
50
+ # new(authzid:, oauth2_token:, **) -> authenticator
51
+ #
52
+ # Creates an Authenticator for the "+XOAUTH2+" SASL mechanism, as specified by
53
+ # Google[https://developers.google.com/gmail/imap/xoauth2-protocol],
54
+ # Microsoft[https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth]
55
+ # and Yahoo[https://senders.yahooinc.com/developer/documentation].
56
+ #
57
+ # === Properties
58
+ #
59
+ # * #username --- the username for the account being accessed.
60
+ #
61
+ # #authzid --- an alias for #username.
62
+ #
63
+ # Note that, unlike some other authenticators, +username+ sets the
64
+ # _authorization_ identity and not the _authentication_ identity. The
65
+ # authenticated identity is established for the client with the OAuth token.
66
+ #
67
+ # * #oauth2_token --- An OAuth2.0 access token which is authorized to access
68
+ # the service for #username.
69
+ #
70
+ # Any other keyword parameters are quietly ignored.
71
+ def initialize(user = nil, token = nil, username: nil, oauth2_token: nil,
72
+ authzid: nil, secret: nil, **)
73
+ @username = authzid || username || user or
74
+ raise ArgumentError, "missing username (authzid)"
75
+ @oauth2_token = oauth2_token || secret || token or
76
+ raise ArgumentError, "missing oauth2_token"
77
+ @done = false
78
+ end
79
+
80
+ # :call-seq:
81
+ # initial_response? -> true
82
+ #
83
+ # +XOAUTH2+ can send an initial client response.
84
+ def initial_response?; true end
85
+
86
+ # Returns the XOAUTH2 formatted response, which combines the +username+
87
+ # with the +oauth2_token+.
88
+ def process(_data)
89
+ build_oauth2_string(@username, @oauth2_token)
90
+ ensure
91
+ @done = true
92
+ end
93
+
94
+ # Returns true when the initial client response was sent.
95
+ #
96
+ # The authentication should not succeed unless this returns true, but it
97
+ # does *not* indicate success.
98
+ def done?; @done end
99
+
100
+ private
101
+
102
+ def build_oauth2_string(username, oauth2_token)
103
+ format("user=%s\1auth=Bearer %s\1\1", username, oauth2_token)
104
+ end
105
+
106
+ end