diff --git a/lib/net/imap.rb b/lib/net/imap.rb index 72fb8b0df..bdfa825ba 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -404,14 +404,14 @@ module Net # # Although IMAP4rev2[https://tools.ietf.org/html/rfc9051] is not supported # yet, Net::IMAP supports several extensions that have been folded into it: - # +ENABLE+, +IDLE+, +MOVE+, +NAMESPACE+, +UIDPLUS+, and +UNSELECT+. Commands - # for these extensions are listed with the - # {Core IMAP commands}[rdoc-ref:Net::IMAP@Core+IMAP+commands], above. + # +ENABLE+, +IDLE+, +MOVE+, +NAMESPACE+, +SASL-IR+, +UIDPLUS+, and +UNSELECT+. + # Commands for these extensions are listed with the {Core IMAP + # commands}[rdoc-ref:Net::IMAP@Core+IMAP+commands], above. # # >>> # The following are folded into +IMAP4rev2+ but are currently # unsupported or incompletely supported by Net::IMAP: RFC4466 - # extensions, +ESEARCH+, +SEARCHRES+, +SASL-IR+, +LIST-EXTENDED+, + # extensions, +ESEARCH+, +SEARCHRES+, +LIST-EXTENDED+, # +LIST-STATUS+, +LITERAL-+, +BINARY+ fetch, and +SPECIAL-USE+. The # following extensions are implicitly supported, but will be updated with # more direct support: RFC5530 response codes, STATUS=SIZE, and @@ -457,6 +457,10 @@ module Net # - Updates #append with the +APPENDUID+ ResponseCode # - Updates #copy, #move with the +COPYUID+ ResponseCode # + # ==== RFC4959: +SASL-IR+ + # Folded into IMAP4rev2[https://tools.ietf.org/html/rfc9051]. + # - Updates #authenticate with the option to send an initial response. + # # ==== RFC5161: +ENABLE+ # Folded into IMAP4rev2[https://tools.ietf.org/html/rfc9051] and also included # above with {Core IMAP commands}[rdoc-ref:Net::IMAP@Core+IMAP+commands]. @@ -983,21 +987,22 @@ def starttls(options = {}, verify = true) end # :call-seq: - # authenticate(mechanism, ...) -> ok_resp - # authenticate(mech, *creds, **props) {|prop, auth| val } -> ok_resp - # authenticate(mechanism, authnid, credentials, authzid=nil) -> ok_resp - # authenticate(mechanism, **properties) -> ok_resp - # authenticate(mechanism) {|propname, authctx| prop_value } -> ok_resp + # authenticate(mechanism, ...) -> ok_resp + # authenticate(mech, *creds, sasl_ir: nil, **props, &callback) -> ok_resp # # Sends an {AUTHENTICATE command [IMAP4rev1 ยง6.2.2]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.2.2] # to authenticate the client. If successful, the connection enters the # "_authenticated_" state. # # +mechanism+ is the name of the \SASL authentication mechanism to be used. + # + # When supported by the chosen mechanism and by the server #capabilities, + # sasl_ir: :auto sends an "initial response" to save a round trip. + # A future version may default to +:auto+. + # # All other arguments are forwarded to the authenticator for the requested - # mechanism. The listed call signatures are suggestions. The - # documentation for each individual mechanism must be consulted for its - # specific parameters. + # mechanism. The documentation for each individual mechanism must be + # consulted for its specific parameters. # # An exception Net::IMAP::NoResponseError is raised if authentication fails. # @@ -1048,19 +1053,38 @@ def starttls(options = {}, verify = true) # raise "No acceptable authentication mechanism is available" # end # - # Server capabilities may change after #starttls, #login, and #authenticate. - # Cached #capabilities will be cleared when this method completes. - # If the TaggedResponse to #authenticate includes updated capabilities, they - # will be cached. + # When the server's capabilities include +SASL-IR+ + # [RFC4959[https://tools.ietf.org/html/rfc4959]], an initial response may be + # sent with +sasl_ir+ set to +true+ or +:auto+. + # + # # The following two commands are equivalent: + # imap.authenticate("PLAIN", user, password, sasl_ir: :auto) + # imap.authenticate("PLAIN", user, password, + # sasl_ir: imap.capable?("SASL-IR")) # - def authenticate(mechanism, ...) - authenticator = self.class.authenticator(mechanism, ...) - send_command("AUTHENTICATE", mechanism) do |resp| + # Server capabilities may change after #starttls, #login, and #authenticate. + # Previously Cached #capabilities will be cleared when this method + # completes. If the TaggedResponse to #authenticate includes updated + # capabilities, they will be cached. + def authenticate(mechanism, *creds, sasl_ir: false, **props, &callback) + [true, false, :auto].include?(sasl_ir) or + raise ArgumentError, "sasl_ir must be boolean or :auto" + sasl_ir = capable?("SASL-IR") if :auto == sasl_ir + authenticator = self.class.authenticator(mechanism, + *creds, + **props, + &callback) + cmdargs = ["AUTHENTICATE", mechanism] + if sasl_ir && SASL.initial_response?(authenticator) + response = authenticator.process(nil) + cmdargs << [response].pack("m0") + end + send_command(*cmdargs) do |resp| if resp.instance_of?(ContinuationRequest) - data = authenticator.process(resp.data.text.unpack("m")[0]) - s = [data].pack("m0") - send_string_data(s) - put_string(CRLF) + challenge = resp.data.text.unpack1("m") + response = authenticator.process(challenge) + response = [response].pack("m0") + put_string(response + CRLF) end end .tap { @capabilities = capabilities_from_resp_code _1 } diff --git a/lib/net/imap/authenticators/plain.rb b/lib/net/imap/authenticators/plain.rb index a9d46c920..e7fe07c4d 100644 --- a/lib/net/imap/authenticators/plain.rb +++ b/lib/net/imap/authenticators/plain.rb @@ -11,6 +11,8 @@ # can be secured by TLS encryption. class Net::IMAP::PlainAuthenticator + def initial_response?; true end + def process(data) return "#@authzid\0#@username\0#@password" end diff --git a/lib/net/imap/sasl.rb b/lib/net/imap/sasl.rb index 658580728..eca77bdd3 100644 --- a/lib/net/imap/sasl.rb +++ b/lib/net/imap/sasl.rb @@ -39,6 +39,10 @@ def saslprep(string, **opts) Net::IMAP::StringPrep::SASLprep.saslprep(string, **opts) end + def initial_response?(mechanism) + mechanism.respond_to?(:initial_response?) && mechanism.initial_response? + end + end end diff --git a/test/net/imap/fake_server/command_reader.rb b/test/net/imap/fake_server/command_reader.rb index 2abe2f8a4..2fd15ef2f 100644 --- a/test/net/imap/fake_server/command_reader.rb +++ b/test/net/imap/fake_server/command_reader.rb @@ -32,7 +32,7 @@ def get_command def parse(buf) /\A([^ ]+) ((?:UID )?\w+)(?: (.+))?\r\n\z/min =~ buf or raise "bad request" case $2.upcase - when "LOGIN", "SELECT", "ENABLE" + when "LOGIN", "SELECT", "ENABLE", "AUTHENTICATE" Command.new $1, $2, scan_astrings($3), buf else Command.new $1, $2, $3, buf # TODO... diff --git a/test/net/imap/fake_server/command_router.rb b/test/net/imap/fake_server/command_router.rb index c99a0a989..96996148b 100644 --- a/test/net/imap/fake_server/command_router.rb +++ b/test/net/imap/fake_server/command_router.rb @@ -79,8 +79,14 @@ def handler_for(command) on "AUTHENTICATE" do |resp| state.not_authenticated? or return resp.fail_bad_state(state) args = resp.command.args - args == "PLAIN" or return resp.fail_no "unsupported" - response_b64 = resp.request_continuation("") || "" + (1..2) === args.length or return resp.fail_bad_args + args.first == "PLAIN" or return resp.fail_no "unsupported" + if args.length == 2 + response_b64 = args.last + else + response_b64 = resp.request_continuation("") || "" + state.commands << {continuation: response_b64} + end response = Base64.decode64(response_b64) response.empty? and return resp.fail_bad "canceled" # TODO: support mechanisms other than PLAIN. diff --git a/test/net/imap/fake_server/configuration.rb b/test/net/imap/fake_server/configuration.rb index b215f019f..91fe72a42 100644 --- a/test/net/imap/fake_server/configuration.rb +++ b/test/net/imap/fake_server/configuration.rb @@ -22,6 +22,7 @@ class Configuration encrypted_login: true, cleartext_auth: false, sasl_mechanisms: %i[PLAIN].freeze, + sasl_ir: false, rev1: true, rev2: false, @@ -66,6 +67,7 @@ def initialize(with_extensions: [], without_extensions: [], **opts, &block) alias cleartext_auth? cleartext_auth alias greeting_bye? greeting_bye alias greeting_capabilities? greeting_capabilities + alias sasl_ir? sasl_ir def on(event, &handler) handler or raise ArgumentError @@ -104,6 +106,7 @@ def capabilities_pre_tls capa << "STARTTLS" if starttls? capa << "LOGINDISABLED" unless cleartext_login? capa.concat auth_capabilities if cleartext_auth? + capa << "SASL-IR" if sasl_ir? && cleartext_auth? capa end @@ -111,6 +114,7 @@ def capabilities_pre_auth capa = basic_capabilities capa << "LOGINDISABLED" unless encrypted_login? capa.concat auth_capabilities + capa << "SASL-IR" if sasl_ir? capa end diff --git a/test/net/imap/test_imap.rb b/test/net/imap/test_imap.rb index 949710227..8af57a4f2 100644 --- a/test/net/imap/test_imap.rb +++ b/test/net/imap/test_imap.rb @@ -776,6 +776,91 @@ def test_id end end + test "#authenticate doesn't send initial response by default" do + [true, false].each do |server_support| + with_fake_server( + preauth: false, cleartext_auth: true, sasl_ir: server_support + ) do |server, imap| + imap.authenticate("PLAIN", "test_user", "test-password") + cmd, cont = 2.times.map { server.commands.pop } + assert_equal %w[AUTHENTICATE PLAIN], [cmd.name, *cmd.args] + assert_equal(["\x00test_user\x00test-password"].pack("m0"), + cont[:continuation].strip) + assert_empty server.commands + end + end + end + + test "#authenticate(sasl_ir: false) doesn't send initial response " do + [true, false].each do |server_support| + with_fake_server( + preauth: false, cleartext_auth: true, sasl_ir: server_support + ) do |server, imap| + imap.authenticate("PLAIN", "test_user", "test-password", sasl_ir: false) + cmd, cont = 2.times.map { server.commands.pop } + assert_equal %w[AUTHENTICATE PLAIN], [cmd.name, *cmd.args] + assert_equal(["\x00test_user\x00test-password"].pack("m0"), + cont[:continuation].strip) + assert_empty server.commands + end + end + end + + test "#authenticate(sasl_ir: :auto) doesn't send if server isn't capable" do + with_fake_server( + preauth: false, cleartext_auth: true, sasl_ir: false + ) do |server, imap| + imap.authenticate("PLAIN", "test_user", "test-password", sasl_ir: :auto) + cmd, cont = 2.times.map { server.commands.pop } + assert_equal %w[AUTHENTICATE PLAIN], [cmd.name, *cmd.args] + assert_equal(["\x00test_user\x00test-password"].pack("m0"), + cont[:continuation].strip) + assert_empty server.commands + end + end + + test "#authenticate(sasl_ir: :auto) sends if server is capable" do + with_fake_server( + preauth: false, cleartext_auth: true, sasl_ir: true + ) do |server, imap| + imap.authenticate("PLAIN", "test_user", "test-password", sasl_ir: true) + cmd = server.commands.pop + assert_equal "AUTHENTICATE", cmd.name + assert_equal(["PLAIN", ["\x00test_user\x00test-password"].pack("m0")], + cmd.args) + assert_empty server.commands + end + end + + test "#authenticate(sasl_ir: true) sends initial response" do + [true, false].each do |server_support| + with_fake_server( + preauth: false, cleartext_auth: true, sasl_ir: server_support + ) do |server, imap| + imap.authenticate("PLAIN", "test_user", "test-password", sasl_ir: true) + cmd = server.commands.pop + assert_equal "AUTHENTICATE", cmd.name + assert_equal(["PLAIN", ["\x00test_user\x00test-password"].pack("m0")], + cmd.args) + assert_empty server.commands + end + end + end + + test "#authenticate(sasl_ir: true) doesn't send IR for incapable mechanism" do + with_fake_server( + preauth: false, cleartext_auth: true, sasl_ir: true + ) do |server, imap| + begin + imap.authenticate("DIGEST-MD5", "test_user", "test-password", + sasl_ir: true, warn_deprecation: false) + rescue Net::IMAP::NoResponseError + end + cmd = server.commands.pop + assert_equal %w[AUTHENTICATE DIGEST-MD5], [cmd.name, *cmd.args] + end + end + def test_uidplus_uid_expunge with_fake_server(select: "INBOX", extensions: %i[UIDPLUS]) do |server, imap| diff --git a/test/net/imap/test_imap_capabilities.rb b/test/net/imap/test_imap_capabilities.rb index 31f4d0ff8..14e1f79ba 100644 --- a/test/net/imap/test_imap_capabilities.rb +++ b/test/net/imap/test_imap_capabilities.rb @@ -190,6 +190,7 @@ def teardown imap.authenticate("PLAIN", "test_user", "test-password") assert_equal "AUTHENTICATE", server.commands.pop.name + assert server.commands.pop[:continuation] refute imap.capabilities_cached? assert imap.capable? :IMAP4rev1 @@ -277,6 +278,7 @@ def teardown rescue Net::IMAP::NoResponseError end assert_equal "AUTHENTICATE", server.commands.pop.name + assert server.commands.pop[:continuation] assert_equal original_capabilities, imap.capabilities assert_empty server.commands end