Skip to content

Commit 92dabbb

Browse files
authored
πŸ”€ Merge pull request #90 from ruby/sasl-ir
βœ¨πŸ”’ Add SASL-IR support
2 parents b8f2986 + aa0229a commit 92dabbb

File tree

10 files changed

+203
-33
lines changed

10 files changed

+203
-33
lines changed

β€Žlib/net/imap.rb

Lines changed: 48 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,9 @@ module Net
103103
#
104104
# == Capabilities
105105
#
106-
# Net::IMAP does not _currently_ modify its behaviour according to the
107-
# server's advertised #capabilities. Users of this class must check that the
108-
# server is capable of extension commands or command arguments before
106+
# Most Net::IMAP methods do not _currently_ modify their behaviour according
107+
# to the server's advertised #capabilities. Users of this class must check
108+
# that the server is capable of extension commands or command arguments before
109109
# sending them. Special care should be taken to follow the #capabilities
110110
# requirements for #starttls, #login, and #authenticate.
111111
#
@@ -404,14 +404,14 @@ module Net
404404
#
405405
# Although IMAP4rev2[https://tools.ietf.org/html/rfc9051] is not supported
406406
# yet, Net::IMAP supports several extensions that have been folded into it:
407-
# +ENABLE+, +IDLE+, +MOVE+, +NAMESPACE+, +UIDPLUS+, and +UNSELECT+. Commands
408-
# for these extensions are listed with the
409-
# {Core IMAP commands}[rdoc-ref:Net::IMAP@Core+IMAP+commands], above.
407+
# +ENABLE+, +IDLE+, +MOVE+, +NAMESPACE+, +SASL-IR+, +UIDPLUS+, and +UNSELECT+.
408+
# Commands for these extensions are listed with the {Core IMAP
409+
# commands}[rdoc-ref:Net::IMAP@Core+IMAP+commands], above.
410410
#
411411
# >>>
412412
# <em>The following are folded into +IMAP4rev2+ but are currently
413413
# unsupported or incompletely supported by</em> Net::IMAP<em>: RFC4466
414-
# extensions, +ESEARCH+, +SEARCHRES+, +SASL-IR+, +LIST-EXTENDED+,
414+
# extensions, +ESEARCH+, +SEARCHRES+, +LIST-EXTENDED+,
415415
# +LIST-STATUS+, +LITERAL-+, +BINARY+ fetch, and +SPECIAL-USE+. The
416416
# following extensions are implicitly supported, but will be updated with
417417
# more direct support: RFC5530 response codes, <tt>STATUS=SIZE</tt>, and
@@ -457,6 +457,10 @@ module Net
457457
# - Updates #append with the +APPENDUID+ ResponseCode
458458
# - Updates #copy, #move with the +COPYUID+ ResponseCode
459459
#
460+
# ==== RFC4959: +SASL-IR+
461+
# Folded into IMAP4rev2[https://tools.ietf.org/html/rfc9051].
462+
# - Updates #authenticate with the option to send an initial response.
463+
#
460464
# ==== RFC5161: +ENABLE+
461465
# Folded into IMAP4rev2[https://tools.ietf.org/html/rfc9051] and also included
462466
# above with {Core IMAP commands}[rdoc-ref:Net::IMAP@Core+IMAP+commands].
@@ -983,19 +987,17 @@ def starttls(options = {}, verify = true)
983987
end
984988

985989
# :call-seq:
986-
# authenticate(mechanism, ...) -> ok_resp
987-
# authenticate(mech, *creds, **props) {|prop, auth| val } -> ok_resp
988-
# authenticate(mechanism, authnid, credentials, authzid=nil) -> ok_resp
989-
# authenticate(mechanism, **properties) -> ok_resp
990-
# authenticate(mechanism) {|propname, authctx| prop_value } -> ok_resp
990+
# authenticate(mechanism, ...) -> ok_resp
991+
# authenticate(mech, *creds, sasl_ir: true, **attrs, &callback) -> ok_resp
991992
#
992993
# Sends an {AUTHENTICATE command [IMAP4rev1 Β§6.2.2]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.2.2]
993994
# to authenticate the client. If successful, the connection enters the
994995
# "_authenticated_" state.
995996
#
996997
# +mechanism+ is the name of the \SASL authentication mechanism to be used.
997-
# All other arguments are forwarded to the authenticator for the requested
998-
# mechanism. The listed call signatures are suggestions. <em>The
998+
# +sasl_ir+ allows or disallows sending an "initial response" (see the
999+
# +SASL-IR+ capability, below). All other arguments are forwarded to the
1000+
# registered SASL authenticator for the requested mechanism. <em>The
9991001
# documentation for each individual mechanism must be consulted for its
10001002
# specific parameters.</em>
10011003
#
@@ -1048,19 +1050,40 @@ def starttls(options = {}, verify = true)
10481050
# raise "No acceptable authentication mechanism is available"
10491051
# end
10501052
#
1051-
# Server capabilities may change after #starttls, #login, and #authenticate.
1052-
# Cached #capabilities will be cleared when this method completes.
1053-
# If the TaggedResponse to #authenticate includes updated capabilities, they
1054-
# will be cached.
1053+
# The SASL exchange provides a method for server challenges and client
1054+
# responses, but many mechanisms expect the client to "respond" first. When
1055+
# the server's capabilities include +SASL-IR+
1056+
# [RFC4959[https://tools.ietf.org/html/rfc4959]], this "initial response"
1057+
# may be sent as an argument to the +AUTHENTICATE+ command, saving a
1058+
# round-trip. The initial response will _only_ be sent when it is supported
1059+
# by both the mechanism and the server. Set +sasl_ir+ to +false+ to prevent
1060+
# sending an initial response, even when it is supported.
10551061
#
1056-
def authenticate(mechanism, ...)
1057-
authenticator = self.class.authenticator(mechanism, ...)
1058-
send_command("AUTHENTICATE", mechanism) do |resp|
1062+
# Although servers _should_ advertise all supported auth mechanisms, it is
1063+
# possible to attempt to authenticate with a +mechanism+ that isn't listed.
1064+
# However the initial response will not be sent unless the appropriate
1065+
# <tt>"AUTH=#{mechanism}"</tt> capability is also present.
1066+
#
1067+
# Server capabilities may change after #starttls, #login, and #authenticate.
1068+
# Previously cached #capabilities will be cleared when this method
1069+
# completes. If the TaggedResponse to #authenticate includes updated
1070+
# capabilities, they will be cached.
1071+
def authenticate(mechanism, *creds, sasl_ir: true, **props, &callback)
1072+
authenticator = self.class.authenticator(mechanism,
1073+
*creds,
1074+
**props,
1075+
&callback)
1076+
cmdargs = ["AUTHENTICATE", mechanism]
1077+
if sasl_ir && capable?("SASL-IR") && auth_capable?(mechanism) &&
1078+
SASL.initial_response?(authenticator)
1079+
cmdargs << [authenticator.process(nil)].pack("m0")
1080+
end
1081+
send_command(*cmdargs) do |resp|
10591082
if resp.instance_of?(ContinuationRequest)
1060-
data = authenticator.process(resp.data.text.unpack("m")[0])
1061-
s = [data].pack("m0")
1062-
send_string_data(s)
1063-
put_string(CRLF)
1083+
challenge = resp.data.text.unpack1("m")
1084+
response = authenticator.process(challenge)
1085+
response = [response].pack("m0")
1086+
put_string(response + CRLF)
10641087
end
10651088
end
10661089
.tap { @capabilities = capabilities_from_resp_code _1 }

β€Žlib/net/imap/authenticators/plain.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
# can be secured by TLS encryption.
1212
class Net::IMAP::PlainAuthenticator
1313

14+
def initial_response?; true end
15+
1416
def process(data)
1517
return "#@authzid\0#@username\0#@password"
1618
end

β€Žlib/net/imap/authenticators/xoauth2.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# frozen_string_literal: true
22

33
class Net::IMAP::XOauth2Authenticator
4+
5+
def initial_response?; true end
6+
47
def process(_data)
58
build_oauth2_string(@user, @oauth2_token)
69
end

β€Žlib/net/imap/sasl.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ def saslprep(string, **opts)
3939
Net::IMAP::StringPrep::SASLprep.saslprep(string, **opts)
4040
end
4141

42+
def initial_response?(mechanism)
43+
mechanism.respond_to?(:initial_response?) && mechanism.initial_response?
44+
end
45+
4246
end
4347
end
4448

β€Žtest/net/imap/fake_server/command_reader.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def get_command
3232
def parse(buf)
3333
/\A([^ ]+) ((?:UID )?\w+)(?: (.+))?\r\n\z/min =~ buf or raise "bad request"
3434
case $2.upcase
35-
when "LOGIN", "SELECT", "ENABLE"
35+
when "LOGIN", "SELECT", "ENABLE", "AUTHENTICATE"
3636
Command.new $1, $2, scan_astrings($3), buf
3737
else
3838
Command.new $1, $2, $3, buf # TODO...

β€Žtest/net/imap/fake_server/command_router.rb

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,17 @@ def handler_for(command)
5555
resp.args.nil? or return resp.fail_bad_args
5656
resp.bye
5757
state.logout
58-
resp.done_ok
58+
begin
59+
resp.done_ok
60+
rescue IOError
61+
# TODO: fix whatever is causing this!
62+
warn "connection issue after bye but before LOGOUT could complete"
63+
if $!.respond_to :detailed_message
64+
warn $!.detailed_message highlight: true, order: :bottom
65+
else
66+
warn $!.full_message highlight: true, order: :bottom
67+
end
68+
end
5969
end
6070

6171
on "STARTTLS" do |resp|
@@ -79,8 +89,14 @@ def handler_for(command)
7989
on "AUTHENTICATE" do |resp|
8090
state.not_authenticated? or return resp.fail_bad_state(state)
8191
args = resp.command.args
82-
args == "PLAIN" or return resp.fail_no "unsupported"
83-
response_b64 = resp.request_continuation("") || ""
92+
(1..2) === args.length or return resp.fail_bad_args
93+
args.first == "PLAIN" or return resp.fail_no "unsupported"
94+
if args.length == 2
95+
response_b64 = args.last
96+
else
97+
response_b64 = resp.request_continuation("") || ""
98+
state.commands << {continuation: response_b64}
99+
end
84100
response = Base64.decode64(response_b64)
85101
response.empty? and return resp.fail_bad "canceled"
86102
# TODO: support mechanisms other than PLAIN.

β€Žtest/net/imap/fake_server/configuration.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class Configuration
2222
encrypted_login: true,
2323
cleartext_auth: false,
2424
sasl_mechanisms: %i[PLAIN].freeze,
25+
sasl_ir: false,
2526

2627
rev1: true,
2728
rev2: false,
@@ -66,6 +67,7 @@ def initialize(with_extensions: [], without_extensions: [], **opts, &block)
6667
alias cleartext_auth? cleartext_auth
6768
alias greeting_bye? greeting_bye
6869
alias greeting_capabilities? greeting_capabilities
70+
alias sasl_ir? sasl_ir
6971

7072
def on(event, &handler)
7173
handler or raise ArgumentError
@@ -104,13 +106,15 @@ def capabilities_pre_tls
104106
capa << "STARTTLS" if starttls?
105107
capa << "LOGINDISABLED" unless cleartext_login?
106108
capa.concat auth_capabilities if cleartext_auth?
109+
capa << "SASL-IR" if sasl_ir? && cleartext_auth?
107110
capa
108111
end
109112

110113
def capabilities_pre_auth
111114
capa = basic_capabilities
112115
capa << "LOGINDISABLED" unless encrypted_login?
113116
capa.concat auth_capabilities
117+
capa << "SASL-IR" if sasl_ir?
114118
capa
115119
end
116120

β€Žtest/net/imap/test_imap.rb

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -776,6 +776,96 @@ def test_id
776776
end
777777
end
778778

779+
test("#authenticate sends an initial response " \
780+
"when supported by both the mechanism and the server") do
781+
with_fake_server(
782+
preauth: false, cleartext_auth: true, sasl_ir: true
783+
) do |server, imap|
784+
imap.authenticate("PLAIN", "test_user", "test-password")
785+
cmd = server.commands.pop
786+
assert_equal "AUTHENTICATE", cmd.name
787+
assert_equal(["PLAIN", ["\x00test_user\x00test-password"].pack("m0")],
788+
cmd.args)
789+
assert_empty server.commands
790+
end
791+
end
792+
793+
test("#authenticate never sends an initial response " \
794+
"when the server doesn't explicitly support the mechanism") do
795+
with_fake_server(
796+
preauth: false, cleartext_auth: true,
797+
sasl_ir: true, sasl_mechanisms: %i[SCRAM-SHA-1 SCRAM-SHA-256],
798+
) do |server, imap|
799+
imap.authenticate("PLAIN", "test_user", "test-password")
800+
cmd, cont = 2.times.map { server.commands.pop }
801+
assert_equal %w[AUTHENTICATE PLAIN], [cmd.name, *cmd.args]
802+
assert_equal(["\x00test_user\x00test-password"].pack("m0"),
803+
cont[:continuation].strip)
804+
assert_empty server.commands
805+
end
806+
end
807+
808+
test("#authenticate never sends an initial response " \
809+
"when the server isn't capable") do
810+
with_fake_server(
811+
preauth: false, cleartext_auth: true, sasl_ir: false
812+
) do |server, imap|
813+
imap.authenticate("PLAIN", "test_user", "test-password")
814+
cmd, cont = 2.times.map { server.commands.pop }
815+
assert_equal %w[AUTHENTICATE PLAIN], [cmd.name, *cmd.args]
816+
assert_equal(["\x00test_user\x00test-password"].pack("m0"),
817+
cont[:continuation].strip)
818+
assert_empty server.commands
819+
end
820+
end
821+
822+
test("#authenticate never sends an initial response " \
823+
"when sasl_ir: false") do
824+
[true, false].each do |server_support|
825+
with_fake_server(
826+
preauth: false, cleartext_auth: true, sasl_ir: server_support
827+
) do |server, imap|
828+
imap.authenticate("PLAIN", "test_user", "test-password", sasl_ir: false)
829+
cmd, cont = 2.times.map { server.commands.pop }
830+
assert_equal %w[AUTHENTICATE PLAIN], [cmd.name, *cmd.args]
831+
assert_equal(["\x00test_user\x00test-password"].pack("m0"),
832+
cont[:continuation].strip)
833+
assert_empty server.commands
834+
end
835+
end
836+
end
837+
838+
test("#authenticate never sends an initial response " \
839+
"when the mechanism does not support client-first") do
840+
with_fake_server(
841+
preauth: false, cleartext_auth: true,
842+
sasl_ir: true, sasl_mechanisms: %i[DIGEST-MD5]
843+
) do |server, imap|
844+
server.on "AUTHENTICATE" do |cmd|
845+
response_b64 = cmd.request_continuation(
846+
[
847+
%w[
848+
realm="somerealm"
849+
nonce="OA6MG9tEQGm2hh"
850+
qop="auth"
851+
charset=utf-8
852+
algorithm=md5-sess
853+
].join(",")
854+
].pack("m0")
855+
)
856+
state.commands << {continuation: response_b64}
857+
server.state.authenticate(server.config.user)
858+
cmd.done_ok
859+
end
860+
imap.authenticate("DIGEST-MD5", "test_user", "test-password",
861+
warn_deprecation: false)
862+
cmd, cont = 2.times.map { server.commands.pop }
863+
assert_equal %w[AUTHENTICATE DIGEST-MD5], [cmd.name, *cmd.args]
864+
assert_match(%r{\A[a-z0-9+/]+=*\z}i, cont[:continuation].strip)
865+
assert_empty server.commands
866+
end
867+
end
868+
779869
def test_uidplus_uid_expunge
780870
with_fake_server(select: "INBOX",
781871
extensions: %i[UIDPLUS]) do |server, imap|

0 commit comments

Comments
Β (0)