diff --git a/lib/net/imap.rb b/lib/net/imap.rb index 72fb8b0df..9a93c5424 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -103,9 +103,9 @@ module Net # # == Capabilities # - # Net::IMAP does not _currently_ modify its behaviour according to the - # server's advertised #capabilities. Users of this class must check that the - # server is capable of extension commands or command arguments before + # Most Net::IMAP methods do not _currently_ modify their behaviour according + # to the server's advertised #capabilities. Users of this class must check + # that the server is capable of extension commands or command arguments before # sending them. Special care should be taken to follow the #capabilities # requirements for #starttls, #login, and #authenticate. # @@ -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,19 +987,17 @@ 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: true, **attrs, &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. - # All other arguments are forwarded to the authenticator for the requested - # mechanism. The listed call signatures are suggestions. The + # +sasl_ir+ allows or disallows sending an "initial response" (see the + # +SASL-IR+ capability, below). All other arguments are forwarded to the + # registered SASL authenticator for the requested mechanism. The # documentation for each individual mechanism must be consulted for its # specific parameters. # @@ -1048,19 +1050,40 @@ 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. + # The SASL exchange provides a method for server challenges and client + # responses, but many mechanisms expect the client to "respond" first. When + # the server's capabilities include +SASL-IR+ + # [RFC4959[https://tools.ietf.org/html/rfc4959]], this "initial response" + # may be sent as an argument to the +AUTHENTICATE+ command, saving a + # round-trip. The initial response will _only_ be sent when it is supported + # by both the mechanism and the server. Set +sasl_ir+ to +false+ to prevent + # sending an initial response, even when it is supported. # - def authenticate(mechanism, ...) - authenticator = self.class.authenticator(mechanism, ...) - send_command("AUTHENTICATE", mechanism) do |resp| + # Although servers _should_ advertise all supported auth mechanisms, it is + # possible to attempt to authenticate with a +mechanism+ that isn't listed. + # However the initial response will not be sent unless the appropriate + # "AUTH=#{mechanism}" capability is also present. + # + # 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: true, **props, &callback) + authenticator = self.class.authenticator(mechanism, + *creds, + **props, + &callback) + cmdargs = ["AUTHENTICATE", mechanism] + if sasl_ir && capable?("SASL-IR") && auth_capable?(mechanism) && + SASL.initial_response?(authenticator) + cmdargs << [authenticator.process(nil)].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/authenticators/xoauth2.rb b/lib/net/imap/authenticators/xoauth2.rb index 410570202..67b6e5aac 100644 --- a/lib/net/imap/authenticators/xoauth2.rb +++ b/lib/net/imap/authenticators/xoauth2.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true class Net::IMAP::XOauth2Authenticator + + def initial_response?; true end + def process(_data) build_oauth2_string(@user, @oauth2_token) 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..3c46daa17 100644 --- a/test/net/imap/fake_server/command_router.rb +++ b/test/net/imap/fake_server/command_router.rb @@ -55,7 +55,17 @@ def handler_for(command) resp.args.nil? or return resp.fail_bad_args resp.bye state.logout - resp.done_ok + begin + resp.done_ok + rescue IOError + # TODO: fix whatever is causing this! + warn "connection issue after bye but before LOGOUT could complete" + if $!.respond_to :detailed_message + warn $!.detailed_message highlight: true, order: :bottom + else + warn $!.full_message highlight: true, order: :bottom + end + end end on "STARTTLS" do |resp| @@ -79,8 +89,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..f794fde2c 100644 --- a/test/net/imap/test_imap.rb +++ b/test/net/imap/test_imap.rb @@ -776,6 +776,96 @@ def test_id end end + test("#authenticate sends an initial response " \ + "when supported by both the mechanism and the server") do + with_fake_server( + preauth: false, cleartext_auth: true, sasl_ir: true + ) do |server, imap| + imap.authenticate("PLAIN", "test_user", "test-password") + 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 never sends an initial response " \ + "when the server doesn't explicitly support the mechanism") do + with_fake_server( + preauth: false, cleartext_auth: true, + sasl_ir: true, sasl_mechanisms: %i[SCRAM-SHA-1 SCRAM-SHA-256], + ) 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 + + test("#authenticate never sends an initial response " \ + "when the 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") + 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 never sends an initial response " \ + "when sasl_ir: false") 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 never sends an initial response " \ + "when the mechanism does not support client-first") do + with_fake_server( + preauth: false, cleartext_auth: true, + sasl_ir: true, sasl_mechanisms: %i[DIGEST-MD5] + ) do |server, imap| + server.on "AUTHENTICATE" do |cmd| + response_b64 = cmd.request_continuation( + [ + %w[ + realm="somerealm" + nonce="OA6MG9tEQGm2hh" + qop="auth" + charset=utf-8 + algorithm=md5-sess + ].join(",") + ].pack("m0") + ) + state.commands << {continuation: response_b64} + server.state.authenticate(server.config.user) + cmd.done_ok + end + imap.authenticate("DIGEST-MD5", "test_user", "test-password", + warn_deprecation: false) + cmd, cont = 2.times.map { server.commands.pop } + assert_equal %w[AUTHENTICATE DIGEST-MD5], [cmd.name, *cmd.args] + assert_match(%r{\A[a-z0-9+/]+=*\z}i, cont[:continuation].strip) + assert_empty server.commands + 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_authenticators.rb b/test/net/imap/test_imap_authenticators.rb index ceffd04af..cdf32e0a3 100644 --- a/test/net/imap/test_imap_authenticators.rb +++ b/test/net/imap/test_imap_authenticators.rb @@ -9,14 +9,17 @@ class IMAPAuthenticatorsTest < Test::Unit::TestCase # PLAIN # ---------------------- - def plain(*args, **kwargs, &block) - Net::IMAP.authenticator("PLAIN", *args, **kwargs, &block) - end + def plain(...) Net::IMAP.authenticator("PLAIN", ...) end def test_plain_authenticator_matches_mechanism assert_kind_of(Net::IMAP::PlainAuthenticator, plain("user", "pass")) end + def test_plain_supports_initial_response + assert plain("foo", "bar").initial_response? + assert Net::IMAP::SASL.initial_response?(plain("foo", "bar")) + end + def test_plain_response assert_equal("\0authc\0passwd", plain("authc", "passwd").process(nil)) assert_equal("authz\0user\0pass", @@ -33,13 +36,24 @@ def test_plain_no_null_chars # XOAUTH2 # ---------------------- + def xoauth2(...) Net::IMAP.authenticator("XOAUTH2", ...) end + + def test_xoauth2_authenticator_matches_mechanism + assert_kind_of(Net::IMAP::XOauth2Authenticator, xoauth2("user", "pass")) + end + def test_xoauth2 assert_equal( "user=username\1auth=Bearer token\1\1", - Net::IMAP::XOauth2Authenticator.new("username", "token").process(nil) + xoauth2("username", "token").process(nil) ) end + def test_xoauth2_supports_initial_response + assert xoauth2("foo", "bar").initial_response? + assert Net::IMAP::SASL.initial_response?(xoauth2("foo", "bar")) + end + # ---------------------- # LOGIN (obsolete) # ---------------------- @@ -54,6 +68,10 @@ def test_login_authenticator_matches_mechanism assert_kind_of(Net::IMAP::LoginAuthenticator, login("n", "p")) end + def test_login_does_not_support_initial_response + refute Net::IMAP::SASL.initial_response?(login("foo", "bar")) + end + def test_login_authenticator_deprecated assert_warn(/LOGIN.+deprecated.+PLAIN/) do Net::IMAP.authenticator("LOGIN", "user", "pass") @@ -80,6 +98,10 @@ def test_cram_md5_authenticator_matches_mechanism assert_kind_of(Net::IMAP::CramMD5Authenticator, cram_md5("n", "p")) end + def test_cram_md5_does_not_support_initial_response + refute Net::IMAP::SASL.initial_response?(cram_md5("foo", "bar")) + end + def test_cram_md5_authenticator_deprecated assert_warn(/CRAM-MD5.+deprecated./) do Net::IMAP.authenticator("CRAM-MD5", "user", "pass") @@ -112,6 +134,10 @@ def test_digest_md5_authenticator_deprecated end end + def test_digest_md5_does_not_support_initial_response + refute Net::IMAP::SASL.initial_response?(digest_md5("foo", "bar")) + end + def test_digest_md5_authenticator auth = digest_md5("cid", "password", "zid") assert_match( 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