Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🔒⚗️ Add experimental SASL::ClientAdapter #183

Merged
merged 2 commits into from
Oct 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions lib/net/imap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -670,8 +670,9 @@ class IMAP < Protocol
"UTF8=ONLY" => "UTF8=ACCEPT",
}.freeze

autoload :SASL, File.expand_path("imap/sasl", __dir__)
autoload :StringPrep, File.expand_path("imap/stringprep", __dir__)
autoload :SASL, File.expand_path("imap/sasl", __dir__)
autoload :SASLAdapter, File.expand_path("imap/sasl_adapter", __dir__)
autoload :StringPrep, File.expand_path("imap/stringprep", __dir__)

include MonitorMixin
if defined?(OpenSSL::SSL)
Expand Down Expand Up @@ -1142,7 +1143,10 @@ def starttls(**options)
end

# :call-seq:
# authenticate(mechanism, *, sasl_ir: true, **, &) -> ok_resp
# authenticate(mechanism, *,
# sasl_ir: true,
# registry: Net::IMAP::SASL.authenticators,
# **, &) -> 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
Expand Down Expand Up @@ -2746,6 +2750,10 @@ def start_tls_session
end
end

def sasl_adapter
SASLAdapter.new(self, &method(:send_command_with_continuations))
end

#--
# We could get the saslprep method by extending the SASLprep module
# directly. It's done indirectly, so SASLprep can be lazily autoloaded,
Expand Down
10 changes: 8 additions & 2 deletions lib/net/imap/sasl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ def initialize(response, message = "authentication ended prematurely")
autoload :BidiStringError, sasl_stringprep_rb

sasl_dir = File.expand_path("sasl", __dir__)
autoload :AuthenticationExchange, "#{sasl_dir}/authentication_exchange"
autoload :ClientAdapter, "#{sasl_dir}/client_adapter"
autoload :ProtocolAdapters, "#{sasl_dir}/protocol_adapters"

autoload :Authenticators, "#{sasl_dir}/authenticators"
autoload :GS2Header, "#{sasl_dir}/gs2_header"
autoload :ScramAlgorithm, "#{sasl_dir}/scram_algorithm"
Expand All @@ -155,8 +159,10 @@ def initialize(response, message = "authentication ended prematurely")
# Returns the default global SASL::Authenticators instance.
def self.authenticators; @authenticators ||= Authenticators.new end

# Delegates to ::authenticators. See Authenticators#authenticator.
def self.authenticator(...) authenticators.authenticator(...) end
# Delegates to <tt>registry.new</tt> See Authenticators#new.
def self.authenticator(*args, registry: authenticators, **kwargs, &block)
registry.new(*args, **kwargs, &block)
end

# Delegates to ::authenticators. See Authenticators#add_authenticator.
def self.add_authenticator(...) authenticators.add_authenticator(...) end
Expand Down
107 changes: 107 additions & 0 deletions lib/net/imap/sasl/authentication_exchange.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# frozen_string_literal: true

module Net
class IMAP
module SASL

# This API is *experimental*, and may change.
#
# TODO: catch exceptions in #process and send #cancel_response.
# TODO: raise an error if the command succeeds after being canceled.
# TODO: use with more clients, to verify the API can accommodate them.
#
# Create an AuthenticationExchange from a client adapter and a mechanism
# authenticator:
# def authenticate(mechanism, ...)
# authenticator = SASL.authenticator(mechanism, ...)
# SASL::AuthenticationExchange.new(
# sasl_adapter, mechanism, authenticator
# ).authenticate
# end
#
# private
#
# def sasl_adapter = MyClientAdapter.new(self, &method(:send_command))
#
# Or delegate creation of the authenticator to ::build:
# def authenticate(...)
# SASL::AuthenticationExchange.build(sasl_adapter, ...)
# .authenticate
# end
#
# As a convenience, ::authenticate combines ::build and #authenticate:
# def authenticate(...)
# SASL::AuthenticationExchange.authenticate(sasl_adapter, ...)
# end
#
# Likewise, ClientAdapter#authenticate delegates to #authenticate:
# def authenticate(...) = sasl_adapter.authenticate(...)
#
class AuthenticationExchange
# Convenience method for <tt>build(...).authenticate</tt>
def self.authenticate(...) build(...).authenticate end

# Use +registry+ to override the global Authenticators registry.
def self.build(client, mechanism, *args, sasl_ir: true, **kwargs, &block)
authenticator = SASL.authenticator(mechanism, *args, **kwargs, &block)
new(client, mechanism, authenticator, sasl_ir: sasl_ir)
end

attr_reader :mechanism, :authenticator

def initialize(client, mechanism, authenticator, sasl_ir: true)
@client = client
@mechanism = -mechanism.to_s.upcase.tr(?_, ?-)
@authenticator = authenticator
@sasl_ir = sasl_ir
@processed = false
end

# Call #authenticate to execute an authentication exchange for #client
# using #authenticator. Authentication failures will raise an
# exception. Any exceptions other than those in RESPONSE_ERRORS will
# drop the connection.
def authenticate
client.run_command(mechanism, initial_response) { process _1 }
.tap { raise AuthenticationIncomplete, _1 unless done? }
rescue *client.response_errors
raise # but don't drop the connection
rescue
client.drop_connection
raise
rescue Exception # rubocop:disable Lint/RescueException
client.drop_connection!
raise
end

def send_initial_response?
@sasl_ir &&
authenticator.respond_to?(:initial_response?) &&
authenticator.initial_response? &&
client.sasl_ir_capable? &&
client.auth_capable?(mechanism)
end

def done?
authenticator.respond_to?(:done?) ? authenticator.done? : @processed
end

private

attr_reader :client

def initial_response
return unless send_initial_response?
client.encode_ir authenticator.process nil
end

def process(challenge)
client.encode authenticator.process client.decode challenge
ensure
@processed = true
end

end
end
end
end
14 changes: 10 additions & 4 deletions lib/net/imap/sasl/authenticators.rb
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def names; @authenticators.keys end
# lazily loaded from <tt>Net::IMAP::SASL::#{name}Authenticator</tt> (case is
# preserved and non-alphanumeric characters are removed..
def add_authenticator(name, authenticator = nil)
key = name.upcase.to_sym
key = -name.to_s.upcase.tr(?_, ?-)
authenticator ||= begin
class_name = "#{name.gsub(/[^a-zA-Z0-9]/, "")}Authenticator".to_sym
auth_class = nil
Expand All @@ -79,10 +79,15 @@ def add_authenticator(name, authenticator = nil)

# Removes the authenticator registered for +name+
def remove_authenticator(name)
key = name.upcase.to_sym
key = -name.to_s.upcase.tr(?_, ?-)
@authenticators.delete(key)
end

def mechanism?(name)
key = -name.to_s.upcase.tr(?_, ?-)
@authenticators.key?(key)
end

# :call-seq:
# authenticator(mechanism, ...) -> auth_session
#
Expand All @@ -100,8 +105,9 @@ def remove_authenticator(name)
# only. Protocol client users should see refer to their client's
# documentation, e.g. Net::IMAP#authenticate.
def authenticator(mechanism, ...)
auth = @authenticators.fetch(mechanism.upcase.to_sym) do
raise ArgumentError, 'unknown auth type - "%s"' % mechanism
key = -mechanism.to_s.upcase.tr(?_, ?-)
auth = @authenticators.fetch(key) do
raise ArgumentError, 'unknown auth type - "%s"' % key
end
auth.respond_to?(:new) ? auth.new(...) : auth.call(...)
end
Expand Down
72 changes: 72 additions & 0 deletions lib/net/imap/sasl/client_adapter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# frozen_string_literal: true

module Net
class IMAP
module SASL

# This API is *experimental*, and may change.
#
# TODO: use with more clients, to verify the API can accommodate them.
#
# An abstract base class for implementing a SASL authentication exchange.
# Different clients will each have their own adapter subclass, overridden
# to match their needs.
#
# Although the default implementations _may_ be sufficient, subclasses
# will probably need to override some methods. Additionally, subclasses
# may need to include a protocol adapter mixin, if the default
# ProtocolAdapters::Generic isn't sufficient.
class ClientAdapter
include ProtocolAdapters::Generic

attr_reader :client, :command_proc

# +command_proc+ can used to avoid exposing private methods on #client.
# It should run a command with the arguments sent to it, yield each
# continuation payload, respond to the server with the result of each
# yield, and return the result. Non-successful results *MUST* raise an
# exception. Exceptions in the block *MUST* cause the command to fail.
#
# Subclasses that override #run_command may use #command_proc for
# other purposes.
def initialize(client, &command_proc)
@client, @command_proc = client, command_proc
end

# Delegates to AuthenticationExchange.authenticate.
def authenticate(...) AuthenticationExchange.authenticate(self, ...) end

# Do the protocol and server both support an initial response?
def sasl_ir_capable?; client.sasl_ir_capable? end

# Does the server advertise support for the mechanism?
def auth_capable?(mechanism); client.auth_capable?(mechanism) end

# Runs the authenticate command with +mechanism+ and +initial_response+.
# When +initial_response+ is nil, an initial response must NOT be sent.
#
# Yields each continuation payload, responds to the server with the
# result of each yield, and returns the result. Non-successful results
# *MUST* raise an exception. Exceptions in the block *MUST* cause the
# command to fail.
#
# Subclasses that override this may use #command_proc differently.
def run_command(mechanism, initial_response = nil, &block)
command_proc or raise Error, "initialize with block or override"
args = [command_name, mechanism, initial_response].compact
command_proc.call(*args, &block)
end

# Returns an array of server responses errors raised by run_command.
# Exceptions in this array won't drop the connection.
def response_errors; [] end

# Drop the connection gracefully.
def drop_connection; client.drop_connection end

# Drop the connection abruptly.
def drop_connection!; client.drop_connection! end
end
end
end
end
45 changes: 45 additions & 0 deletions lib/net/imap/sasl/protocol_adapters.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# frozen_string_literal: true

module Net
class IMAP
module SASL

module ProtocolAdapters
# This API is experimental, and may change.
module Generic
def command_name; "AUTHENTICATE" end
def service; raise "Implement in subclass or module" end
def host; client.host end
def port; client.port end
def encode_ir(string) string.empty? ? "=" : encode(string) end
def encode(string) [string].pack("m0") end
def decode(string) string.unpack1("m0") end
def cancel_response; "*" end
end

# See RFC-3501 (IMAP4rev1), RFC-4959 (SASL-IR capability),
# and RFC-9051 (IMAP4rev2).
module IMAP
include Generic
def service; "imap" end
end

# See RFC-4954 (AUTH capability).
module SMTP
include Generic
def command_name; "AUTH" end
def service; "smtp" end
end

# See RFC-5034 (SASL capability).
module POP
include Generic
def command_name; "AUTH" end
def service; "pop" end
end

end

end
end
end
21 changes: 21 additions & 0 deletions lib/net/imap/sasl_adapter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

module Net
class IMAP

# Experimental
class SASLAdapter < SASL::ClientAdapter
include SASL::ProtocolAdapters::IMAP

RESPONSE_ERRORS = [NoResponseError, BadResponseError, ByeResponseError]
.freeze

def response_errors; RESPONSE_ERRORS end
def sasl_ir_capable?; client.capable?("SASL-IR") end
def auth_capable?(mechanism); client.auth_capable?(mechanism) end
def drop_connection; client.logout! end
def drop_connection!; client.disconnect end
end

end
end