Skip to content

Commit

Permalink
✨ SASL SCRAM-SHA-*: Add mechanisms [🚧 tests, split commit]
Browse files Browse the repository at this point in the history
Also, don't forget to credit the PR on net-sasl for getting this
started!
  • Loading branch information
nevans committed Nov 21, 2022
1 parent c735063 commit 1949121
Show file tree
Hide file tree
Showing 8 changed files with 672 additions and 55 deletions.
59 changes: 41 additions & 18 deletions lib/net/imap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -805,32 +805,55 @@ def starttls(options = {}, verify = true)
#
# ==== Supported SASL Mechanisms
#
#--
# n.b. the following table is copy/pasted from SASL::Authenticator.
#++
#
# Net::IMAP currently supports the following mechanisms:
#
# PLAIN:: Login using clear-text user and password. Secure with TLS.
# See SASL::PlainAuthenticator.
# ANONYMOUS:: Allow the user to gain access to public services or resources
# without authenticating or disclosing identity to the server.
# See SASL::AnonymousAuthenticator.
# XOAUTH2:: Login using a username and OAuth2 access token. Non-standard
# and obsoleted by +OAUTHBEARER+, but still widely supported.
# See SASL::XOAuth2Authenticator.
# +PLAIN+:: See SASL::PlainAuthenticator.
# Login using clear-text username and password.
# +SCRAM-*+:: See SASL::ScramAuthenticator.
# Login by username and password. The password is not sent
# to the server but is used in a salted challenge/response
# exchange. One of the benefits over +PLAIN+ is that the
# server cannot impersonate the user to other servers.
# +SCRAM-SHA-1+ and +SCRAM-SHA-256+ are supported, but any
# algorithm supported by OpenSSL::Digest can easily be
# added.
# +OAUTHBEARER+:: See SASL::OAuthBearerAuthenticator.
# Login using an OAUTH2 Bearer token. This is the
# standard mechanism for using OAuth2 with \SASL, but it
# is not yet deployed as widely as +XOAUTH2+.
# +XOAUTH2+:: See SASL::XOAuth2Authenticator.
# Login using a username and OAuth2 access token.
# Non-standard and obsoleted by +OAUTHBEARER+, but widely
# supported.
# +EXTERNAL+:: See SASL::ExternalAuthenticator.
# Login using already established credentials, such as a TLS
# certificate or IPsec.
# +ANONYMOUS+:: See SASL::AnonymousAuthenticator.
# Allow the user to gain access to public services or
# resources without authenticating or disclosing an
# identity.
#
# >>>
# *Deprecated:* <em>Obsolete mechanisms are available for backwards
# compatibility.</em>
#
# For +DIGEST-MD5+ see SASL::DigestMD5Authenticator.
#
# For +LOGIN+, see SASL::LoginAuthenticator.
#
# For +CRAM-MD5+, see SASL::CramMD5Authenticator.
#
# <em>Using a deprecated mechanism will print a warning.</em>
#
# See Net::IMAP::Authenticators for information on plugging in
# authenticators for other mechanisms. See the {SASL mechanism
# registry}[https://www.iana.org/assignments/sasl-mechanisms/sasl-mechanisms.xhtml]
# for information on these and other SASL mechanisms.
#
# ===== Deprecated mechanisms
#
# <em>Obsolete mechanisms are available for backwards compatibility.
# Using a deprecated mechanism will print a warning.</em>
#
# DIGEST-MD5:: DEPRECATED by RFC6331. Must be secured using TLS.
# See SASL::DigestMD5Authenticator.
# CRAM-MD5:: DEPRECATED: Use +PLAIN+ (or SCRAM-*)
# LOGIN:: DEPRECATED: Use +PLAIN+ with TLS.
#
# ==== Capabilities
#
# Clients MUST NOT attempt to #authenticate or #login when +LOGINDISABLED+
Expand Down
1 change: 1 addition & 0 deletions lib/net/imap/authenticators.rb
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ def authenticators
require_relative "sasl/anonymous_authenticator"
require_relative "sasl/external_authenticator"
require_relative "sasl/oauthbearer_authenticator"
require_relative "sasl/scram_authenticator"

# deprecated
require_relative "sasl/login_authenticator"
Expand Down
10 changes: 10 additions & 0 deletions lib/net/imap/sasl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ module SASL
autoload :StringPrep, File.expand_path("sasl/stringprep", __dir__)
autoload :SASLprep, File.expand_path("#{__dir__}/sasl/saslprep", __dir__)

# Error raised when the client SASL::Authenticator determines that it
# cannot complete successfully during a call to Authenticator#process.
#
# Note that most \SASL mechanisms cannot detect or report errors until the
# protocol-specific outcome message, e.g. a tagged response in \IMAP.
# Those authentication errors will be handled or raised by the protocol
# client, e.g. a Net::IMAP::NoResponseError.
class AuthenticationFailure < Error
end

# ArgumentError raised when +string+ is invalid for the stringprep
# +profile+.
class StringPrepError < ArgumentError
Expand Down
53 changes: 41 additions & 12 deletions lib/net/imap/sasl/authenticator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,47 @@ module SASL
# please consult the documentation for the specific mechanisms you are
# using:
#
# * +PLAIN+ --- PlainAuthenticator
# * +XOAUTH2+ --- XOAuth2Authenticator
# * +EXTERNAL+ --- ExternalAuthenticator
# * +ANONYMOUS+ --- AnonymousAuthenticator
# * +OAUTHBEARER+ --- OAuthBearerAuthenticator
# * +SCRAM-SHA-*+ --- TODO
#--
# n.b. the following table is copy/pasted to Net::IMAP#authenticate.
#++
#
# +PLAIN+:: See SASL::PlainAuthenticator.
# Login using clear-text username and password.
# +SCRAM-*+:: See SASL::ScramAuthenticator.
# Login by username and password. The password is not sent
# to the server but is used in a salted challenge/response
# exchange. One of the benefits over +PLAIN+ is that the
# server cannot impersonate the user to other servers.
# +SCRAM-SHA-1+ and +SCRAM-SHA-256+ are supported, but any
# algorithm supported by OpenSSL::Digest can easily be
# added.
# +OAUTHBEARER+:: See SASL::OAuthBearerAuthenticator.
# Login using an OAUTH2 Bearer token. This is the
# standard mechanism for using OAuth2 with \SASL, but it
# is not yet deployed as widely as +XOAUTH2+.
# +XOAUTH2+:: See SASL::XOAuth2Authenticator.
# Login using a username and OAuth2 access token.
# Non-standard and obsoleted by +OAUTHBEARER+, but widely
# supported.
# +EXTERNAL+:: See SASL::ExternalAuthenticator.
# Login using already established credentials, such as a TLS
# certificate or IPsec.
# +ANONYMOUS+:: See SASL::AnonymousAuthenticator.
# Allow the user to gain access to public services or
# resources without authenticating or disclosing an
# identity.
#
# >>>
# *Deprecated:* <em>Obsolete mechanisms are available for backwards
# compatibility.</em>
#
# For +DIGEST-MD5+ see SASL::DigestMD5Authenticator.
#
# For +LOGIN+, see SASL::LoginAuthenticator.
#
# For +CRAM-MD5+, see SASL::CramMD5Authenticator.
#
# [Deprecated:]
# DIGEST-MD5[rdoc-ref:DigestMD5Authenticator],
# LOGIN[rdoc-ref:LoginAuthenticator], and
# CRAM-MD5[rdoc-ref:CramMD5Authenticator]
# <em>Using a deprecated mechanism will print a warning.</em>
#
# \Authenticators should be created and used internally by a protocol
# client's authentication command, e.g. Net::IMAP#authenticate for \IMAP.
Expand Down Expand Up @@ -76,7 +106,6 @@ module SASL
# * +scram_sha1_salted_passwords+, +scram_sha256_salted_password+ ---
# Salted password(s) (with salt and iteration count) for the +SCRAM-*+
# mechanism family. <tt>[salt, iterations, pbkdf2_hmac]</tt> tuple.
# <em>(not implemented yet...)</em>
# * +passcode+ --- passcode for SecurID 2FA <em>(not implemented)</em>
# * +pin+ --- Personal Identification number, e.g. for SecurID 2FA
# <em>(not implemented)</em>
Expand Down Expand Up @@ -277,7 +306,7 @@ def initialize(*, **, &callback)
# See PlainAuthenticator or DigestMD5Authenticator for example
# authenticator implementations.
def process(server_challenge_string)
raise NotImplementedError, "#{__method__} is defined by subclasses"
raise NoMethodError, "#{__method__} is defined by subclasses"
end

# :call-seq:
Expand Down
81 changes: 81 additions & 0 deletions lib/net/imap/sasl/gs2_header.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# frozen_string_literal: true

module Net
class IMAP < Protocol
module SASL

# Several mechanisms start with a GS2 header:
# * +GS2-*+
# * +SCRAM-*+ --- ScramAuthenticator
# * +OPENID20+
# * +SAML20+
# * +OAUTH10A+
# * +OAUTHBEARER+ --- OAuthBearerAuthenticator
#
# Classes that include this must implement +#authzid+.
module GS2Header
NO_NULL_CHARS = /\A[^\x00]+\z/u # :nodoc:

##
# Matches {RFC5801 §4}[https://www.rfc-editor.org/rfc/rfc5801#section-4]
# +saslname+. The output from gs2_saslname_encode matches this Regexp.
RFC5801_SASLNAME = /\A(?:[^,=\x00]|=2C|=3D)+\z/u

# The {RFC5801 §4}[https://www.rfc-editor.org/rfc/rfc5801#section-4]
# +gs2-header+, which prefixes the #initial_client_response.
#
# >>>
# <em>Note: the actual GS2 header includes an optional flag to
# indicate that the GSS mechanism is not "standard", but since all of
# the SASL mechanisms using GS2 are "standard", we don't include that
# flag. A class for a nonstandard GSSAPI mechanism should prefix with
# "+F,+".</em>
def gs2_header
"#{gs2_cb_flag},#{gs2_authzid},"
end

# The {RFC5801 §4}[https://www.rfc-editor.org/rfc/rfc5801#section-4]
# +gs2-cb-flag+:
#
# "+n+":: The client doesn't support channel binding.
# "+y+":: The client does support channel binding
# but thinks the server does not.
# "+p+":: The client requires channel binding.
# The selected channel binding follows "+p=+".
#
# The default always returns "+n+". A mechanism that supports channel
# binding must override this method.
#
def gs2_cb_flag; "n" end

# The {RFC5801 §4}[https://www.rfc-editor.org/rfc/rfc5801#section-4]
# +gs2-authzid+ header, when +#authzid+ is not empty.
#
# If +#authzid+ is empty or +nil+, an empty string is returned.
def gs2_authzid
return "" if authzid.nil? || authzid == ""
"a=#{gs2_saslname_encode(authzid)}"
end

module_function

# Encodes +str+ to match RFC5801_SASLNAME.
#
#--
# TODO: validate NO_NULL_CHARS and valid UTF-8 in the attr_writer.
def gs2_saslname_encode(str)
str = str.encode("UTF-8")
if NO_NULL_CHARS.match str
str
.gsub(?=, "=3D")
.gsub(?,, "=2C")
else
# Regexp#match raises "invalid byte sequence" for invalid UTF-8
raise ArgumentError, "invalid saslname: %p" % [str]
end
end

end
end
end
end
31 changes: 6 additions & 25 deletions lib/net/imap/sasl/oauthbearer_authenticator.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require_relative "authenticator"
require_relative "gs2_header"

module Net
class IMAP < Protocol
Expand All @@ -11,15 +12,11 @@ module SASL
# * OAUTHBEARER[rdoc-ref:OAuthBearerAuthenticator]
# * OAUTH10A
class OAuthAuthenticator < Authenticator
include GS2Header

# <b>Implemented by subclasses.</b>
def self.mechanism_name; raise NotImplementedError end

##
# #authzid must match this Regexp. From
# RFC5801[https://www.rfc-editor.org/rfc/rfc5801]:
# saslname = 1*(UTF8-char-safe / "=2C" / "=3D")
RFC5801_SASLNAME = /\A(?:[^,=\x00]+|=2C|=3D)\z/u

# Creates an OAuthBearerAuthenticator or OAuth10aAuthenticator.
#
# * +_subclass_var_+ — the subclass's required parameter.
Expand Down Expand Up @@ -104,32 +101,16 @@ def process(data)
##
# Returns true when the initial client response was sent.
#
# The authentication should not succeed until this is true, but this
# The authentication should not succeed unless this returns true, but it
# does *not* indicate success.
def done?; @done end

# The {RFC7628 §3.1}[https://www.rfc-editor.org/rfc/rfc7628#section-3.1] formatted response.
# The {RFC7628 §3.1}[https://www.rfc-editor.org/rfc/rfc7628#section-3.1]
# formatted response.
def initial_client_response
[gs2_header, *kv_pairs.map {|kv| kv.join("=") }, "\1"].join("\1")
end

# The {RFC5801 §4}[https://www.rfc-editor.org/rfc/rfc5801#section-4]
# +gs2-header+, which prefixes the #initial_client_response.
#
# The +OAUTHBEARER+ and +OAUTH10A+ mechanisms don't use
# +gs2-nonstd-flag+ and don't support channel binding. So the
# +gs2-header+ is always either "<tt>n,a=#{authzid},</tt>" or
# "<tt>n,,</tt>"
def gs2_header
if authzid.nil? then "n,,"
elsif RFC5801_SASLNAME.match? authzid then "n,a=#{authzid},"
else
# TODO: validate in the attr_writer
# Regexp#match? raises "invalid byte sequence" for invalid UTF-8
raise ArgumentError, "invalid chars in authzid %p" % [authzid]
end
end

# The key value pairs which follow gs2_header, as a Hash.
def kv_pairs
{
Expand Down
Loading

0 comments on commit 1949121

Please sign in to comment.