Skip to content

Commit

Permalink
✨ Add basic SASL-IR support to #authenticate
Browse files Browse the repository at this point in the history
I decided to enable SASL-IR by default.  Because we check server
capabilities (including #auth_capable?), this should be safe.  This is
the first (but not the last) command in Net::IMAP that changes its
behavior based on #capabilities.
  • Loading branch information
nevans committed Aug 4, 2023
1 parent b8f2986 commit 30868ae
Show file tree
Hide file tree
Showing 10 changed files with 192 additions and 32 deletions.
73 changes: 48 additions & 25 deletions lib/net/imap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand Down Expand Up @@ -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.
#
# >>>
# <em>The following are folded into +IMAP4rev2+ but are currently
# unsupported or incompletely supported by</em> Net::IMAP<em>: 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, <tt>STATUS=SIZE</tt>, and
Expand Down Expand Up @@ -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].
Expand Down Expand Up @@ -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. <em>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. <em>The
# documentation for each individual mechanism must be consulted for its
# specific parameters.</em>
#
Expand Down Expand Up @@ -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
# <tt>"AUTH=#{mechanism}"</tt> 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 }
Expand Down
2 changes: 2 additions & 0 deletions lib/net/imap/authenticators/plain.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions lib/net/imap/authenticators/xoauth2.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 4 additions & 0 deletions lib/net/imap/sasl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion test/net/imap/fake_server/command_reader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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...
Expand Down
10 changes: 8 additions & 2 deletions test/net/imap/fake_server/command_router.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions test/net/imap/fake_server/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class Configuration
encrypted_login: true,
cleartext_auth: false,
sasl_mechanisms: %i[PLAIN].freeze,
sasl_ir: false,

rev1: true,
rev2: false,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -104,13 +106,15 @@ 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

def capabilities_pre_auth
capa = basic_capabilities
capa << "LOGINDISABLED" unless encrypted_login?
capa.concat auth_capabilities
capa << "SASL-IR" if sasl_ir?
capa
end

Expand Down
90 changes: 90 additions & 0 deletions test/net/imap/test_imap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down
34 changes: 30 additions & 4 deletions test/net/imap/test_imap_authenticators.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)
# ----------------------
Expand All @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -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(
Expand Down
Loading

0 comments on commit 30868ae

Please sign in to comment.