diff --git a/lib/net/imap.rb b/lib/net/imap.rb
index 1368ba303..94bcfb44d 100644
--- a/lib/net/imap.rb
+++ b/lib/net/imap.rb
@@ -1183,6 +1183,17 @@ def starttls(**options)
#
# Login using clear-text username and password.
#
+ # +SCRAM-SHA-1+::
+ # +SCRAM-SHA-256+::
+ # See ScramAuthenticator[rdoc-ref:Net::IMAP::SASL::ScramAuthenticator].
+ #
+ # Login by username and password. The password is not sent to the
+ # server but is used in a salted challenge/response exchange.
+ # +SCRAM-SHA-1+ and +SCRAM-SHA-256+ are directly supported by
+ # Net::IMAP::SASL. New authenticators can easily be added for any other
+ # SCRAM-* mechanism if the digest algorithm is supported by
+ # OpenSSL::Digest.
+ #
# +XOAUTH2+::
# See XOAuth2Authenticator[rdoc-ref:Net::IMAP::SASL::XOAuth2Authenticator].
#
diff --git a/lib/net/imap/sasl.rb b/lib/net/imap/sasl.rb
index ffcea0306..5f01371b9 100644
--- a/lib/net/imap/sasl.rb
+++ b/lib/net/imap/sasl.rb
@@ -51,6 +51,17 @@ class IMAP
#
# Login using clear-text username and password.
#
+ # +SCRAM-SHA-1+::
+ # +SCRAM-SHA-256+::
+ # See ScramAuthenticator.
+ #
+ # Login by username and password. The password is not sent to the
+ # server but is used in a salted challenge/response exchange.
+ # +SCRAM-SHA-1+ and +SCRAM-SHA-256+ are directly supported by
+ # Net::IMAP::SASL. New authenticators can easily be added for any other
+ # SCRAM-* mechanism if the digest algorithm is supported by
+ # OpenSSL::Digest.
+ #
# +XOAUTH2+::
# See XOAuth2Authenticator.
#
@@ -114,11 +125,15 @@ module SASL
sasl_dir = File.expand_path("sasl", __dir__)
autoload :Authenticators, "#{sasl_dir}/authenticators"
autoload :GS2Header, "#{sasl_dir}/gs2_header"
+ autoload :ScramAlgorithm, "#{sasl_dir}/scram_algorithm"
autoload :AnonymousAuthenticator, "#{sasl_dir}/anonymous_authenticator"
autoload :ExternalAuthenticator, "#{sasl_dir}/external_authenticator"
autoload :OAuthBearerAuthenticator, "#{sasl_dir}/oauthbearer_authenticator"
autoload :PlainAuthenticator, "#{sasl_dir}/plain_authenticator"
+ autoload :ScramAuthenticator, "#{sasl_dir}/scram_authenticator"
+ autoload :ScramSHA1Authenticator, "#{sasl_dir}/scram_authenticator"
+ autoload :ScramSHA256Authenticator, "#{sasl_dir}/scram_authenticator"
autoload :XOAuth2Authenticator, "#{sasl_dir}/xoauth2_authenticator"
autoload :CramMD5Authenticator, "#{sasl_dir}/cram_md5_authenticator"
diff --git a/lib/net/imap/sasl/authenticators.rb b/lib/net/imap/sasl/authenticators.rb
index ed55d1c6e..88d5feb58 100644
--- a/lib/net/imap/sasl/authenticators.rb
+++ b/lib/net/imap/sasl/authenticators.rb
@@ -37,6 +37,8 @@ def initialize(use_defaults: false)
add_authenticator "External"
add_authenticator "OAuthBearer"
add_authenticator "Plain"
+ add_authenticator "Scram-SHA-1"
+ add_authenticator "Scram-SHA-256"
add_authenticator "XOAuth2"
add_authenticator "Login" # deprecated
add_authenticator "Cram-MD5" # deprecated
diff --git a/lib/net/imap/sasl/gs2_header.rb b/lib/net/imap/sasl/gs2_header.rb
index 96c530b94..c20a59ef9 100644
--- a/lib/net/imap/sasl/gs2_header.rb
+++ b/lib/net/imap/sasl/gs2_header.rb
@@ -9,6 +9,7 @@ module SASL
# several different mechanisms start with a GS2 header:
# * +GS2-*+ --- RFC5801[https://tools.ietf.org/html/rfc5801]
# * +SCRAM-*+ --- RFC5802[https://tools.ietf.org/html/rfc5802]
+ # (ScramAuthenticator)
# * +SAML20+ --- RFC6595[https://tools.ietf.org/html/rfc6595]
# * +OPENID20+ --- RFC6616[https://tools.ietf.org/html/rfc6616]
# * +OAUTH10A+ --- RFC7628[https://tools.ietf.org/html/rfc7628]
diff --git a/lib/net/imap/sasl/scram_algorithm.rb b/lib/net/imap/sasl/scram_algorithm.rb
new file mode 100644
index 000000000..efe7201ea
--- /dev/null
+++ b/lib/net/imap/sasl/scram_algorithm.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module Net
+ class IMAP
+ module SASL
+
+ # For method descriptions,
+ # see {RFC5802 §2.2}[https://www.rfc-editor.org/rfc/rfc5802#section-2.2]
+ # and {RFC5802 §3}[https://www.rfc-editor.org/rfc/rfc5802#section-3].
+ module ScramAlgorithm
+ def Normalize(str) SASL.saslprep(str) end
+
+ def Hi(str, salt, iterations)
+ length = digest.digest_length
+ OpenSSL::KDF.pbkdf2_hmac(
+ str,
+ salt: salt,
+ iterations: iterations,
+ length: length,
+ hash: digest,
+ )
+ end
+
+ def H(str) digest.digest str end
+
+ def HMAC(key, data) OpenSSL::HMAC.digest(digest, key, data) end
+
+ def XOR(str1, str2)
+ str1.unpack("C*")
+ .zip(str2.unpack("C*"))
+ .map {|a, b| a ^ b }
+ .pack("C*")
+ end
+
+ def auth_message
+ [
+ client_first_message_bare,
+ server_first_message,
+ client_final_message_without_proof,
+ ]
+ .join(",")
+ end
+
+ def salted_password
+ Hi(Normalize(password), salt, iterations)
+ end
+
+ def client_key; HMAC(salted_password, "Client Key") end
+ def server_key; HMAC(salted_password, "Server Key") end
+ def stored_key; H(client_key) end
+ def client_signature; HMAC(stored_key, auth_message) end
+ def server_signature; HMAC(server_key, auth_message) end
+ def client_proof; XOR(client_key, client_signature) end
+ end
+
+ end
+ end
+end
diff --git a/lib/net/imap/sasl/scram_authenticator.rb b/lib/net/imap/sasl/scram_authenticator.rb
new file mode 100644
index 000000000..d8d36947e
--- /dev/null
+++ b/lib/net/imap/sasl/scram_authenticator.rb
@@ -0,0 +1,278 @@
+# frozen_string_literal: true
+
+require "openssl"
+require "securerandom"
+
+require_relative "gs2_header"
+require_relative "scram_algorithm"
+
+module Net
+ class IMAP
+ module SASL
+
+ # Abstract base class for the "+SCRAM-*+" family of SASL mechanisms,
+ # defined in RFC5802[https://tools.ietf.org/html/rfc5802]. Use via
+ # Net::IMAP#authenticate.
+ #
+ # Directly supported:
+ # * +SCRAM-SHA-1+ --- ScramSHA1Authenticator
+ # * +SCRAM-SHA-256+ --- ScramSHA256Authenticator
+ #
+ # New +SCRAM-*+ mechanisms can easily be added for any hash algorithm
+ # supported by
+ # OpenSSL::Digest[https://ruby.github.io/openssl/OpenSSL/Digest.html].
+ # Subclasses need only set an appropriate +DIGEST_NAME+ constant.
+ #
+ # === SCRAM algorithm
+ #
+ # See the documentation and method definitions on ScramAlgorithm for an
+ # overview of the algorithm. The different mechanisms differ only by
+ # which hash function that is used (or by support for channel binding with
+ # +-PLUS+).
+ #
+ # See also the methods on GS2Header.
+ #
+ # ==== Server messages
+ #
+ # As server messages are received, they are validated and loaded into
+ # the various attributes, e.g: #snonce, #salt, #iterations, #verifier,
+ # #server_error, etc.
+ #
+ # Unlike many other SASL mechanisms, the +SCRAM-*+ family supports mutual
+ # authentication and can return server error data in the server messages.
+ # If #process raises an Error for the server-final-message, then
+ # server_error may contain error details.
+ #
+ # === TLS Channel binding
+ #
+ # The SCRAM-*-PLUS mechanisms and channel binding are not
+ # supported yet.
+ #
+ # === Caching SCRAM secrets
+ #
+ # Caching of salted_password, client_key, stored_key, and server_key
+ # is not supported yet.
+ #
+ class ScramAuthenticator
+ include GS2Header
+ include ScramAlgorithm
+
+ # :call-seq:
+ # new(username, password, **options) -> auth_ctx
+ # new(username:, password:, **options) -> auth_ctx
+ #
+ # Creates an authenticator for one of the "+SCRAM-*+" SASL mechanisms.
+ # Each subclass defines #digest to match a specific mechanism.
+ #
+ # Called by Net::IMAP#authenticate and similar methods on other clients.
+ #
+ # === Parameters
+ #
+ # * #username ― Identity whose #password is used. Aliased as #authcid.
+ # * #password ― Password or passphrase associated with this #username.
+ # * #authzid ― Alternate identity to act as or on behalf of. Optional.
+ # * #min_iterations - Overrides the default value (4096). Optional.
+ #
+ # See the documentation on the corresponding attributes for more.
+ def initialize(username_arg = nil, password_arg = nil,
+ username: nil, password: nil, authcid: nil, authzid: nil,
+ min_iterations: 4096, # see both RFC5802 and RFC7677
+ cnonce: nil, # must only be set in tests
+ **options)
+ @username = username || username_arg || authcid or
+ raise ArgumentError, "missing username (authcid)"
+ [username, username_arg, authcid].compact.count == 1 or
+ raise ArgumentError, "conflicting values for username (authcid)"
+ @password = password || password_arg or
+ raise ArgumentError, "missing password"
+ [password, password_arg].compact.count == 1 or
+ raise ArgumentError, "conflicting values for password"
+ @authzid = authzid
+
+ @min_iterations = Integer min_iterations
+ @min_iterations.positive? or
+ raise ArgumentError, "min_iterations must be positive"
+ @cnonce = cnonce || SecureRandom.base64(32)
+ end
+
+ # Authentication identity: the identity that matches the #password.
+ attr_reader :username
+ alias authcid username
+
+ # A password or passphrase that matches the #username.
+ attr_reader :password
+
+ # Authorization identity: an identity to act as or on behalf of. The
+ # identity form is application protocol specific. If not provided or
+ # left blank, the server derives an authorization identity from the
+ # authentication identity. For example, an administrator or superuser
+ # might take on another role:
+ #
+ # imap.authenticate "SCRAM-SHA-256", "root", passwd, authzid: "user"
+ #
+ # The server is responsible for verifying the client's credentials and
+ # verifying that the identity it associates with the client's
+ # authentication identity is allowed to act as (or on behalf of) the
+ # authorization identity.
+ attr_reader :authzid
+
+ # The minimal allowed iteration count. Lower #iterations will raise an
+ # Error.
+ attr_reader :min_iterations
+
+ # The client nonce, generated by SecureRandom
+ attr_reader :cnonce
+
+ # The server nonce, which must start with #cnonce
+ attr_reader :snonce
+
+ # The salt used by the server for this user
+ attr_reader :salt
+
+ # The iteration count for the selected hash function and user
+ attr_reader :iterations
+
+ # An error reported by the server during the \SASL exchange.
+ #
+ # Does not include errors reported by the protocol, e.g.
+ # Net::IMAP::NoResponseError.
+ attr_reader :server_error
+
+ # Returns a new OpenSSL::Digest object, set to the appropriate hash
+ # function for the chosen mechanism.
+ #
+ # The class's +DIGEST_NAME+ constant must be set to the name of an
+ # algorithm supported by OpenSSL::Digest.
+ def digest; OpenSSL::Digest.new self.class::DIGEST_NAME end
+
+ # See {RFC5802 §7}[https://www.rfc-editor.org/rfc/rfc5802#section-7]
+ # +client-first-message+.
+ def initial_client_response
+ "#{gs2_header}#{client_first_message_bare}"
+ end
+
+ # responds to the server's challenges
+ def process(challenge)
+ case (@state ||= :initial_client_response)
+ when :initial_client_response
+ initial_client_response.tap { @state = :server_first_message }
+ when :server_first_message
+ recv_server_first_message challenge
+ final_message_with_proof.tap { @state = :server_final_message }
+ when :server_final_message
+ recv_server_final_message challenge
+ "".tap { @state = :done }
+ else
+ raise Error, "server sent after complete, %p" % [challenge]
+ end
+ rescue Exception => ex
+ @state = ex
+ raise
+ end
+
+ # Is the authentication exchange complete?
+ #
+ # If false, another server continuation is required.
+ def done?; @state == :done end
+
+ private
+
+ # Need to store this for auth_message
+ attr_reader :server_first_message
+
+ def format_message(hash) hash.map { _1.join("=") }.join(",") end
+
+ def recv_server_first_message(server_first_message)
+ @server_first_message = server_first_message
+ sparams = parse_challenge server_first_message
+ @snonce = sparams["r"] or
+ raise Error, "server did not send nonce"
+ @salt = sparams["s"]&.unpack1("m") or
+ raise Error, "server did not send salt"
+ @iterations = sparams["i"]&.then {|i| Integer i } or
+ raise Error, "server did not send iteration count"
+ min_iterations <= iterations or
+ raise Error, "too few iterations: %d" % [iterations]
+ mext = sparams["m"] and
+ raise Error, "mandatory extension: %p" % [mext]
+ snonce.start_with? cnonce or
+ raise Error, "invalid server nonce"
+ end
+
+ def recv_server_final_message(server_final_message)
+ sparams = parse_challenge server_final_message
+ @server_error = sparams["e"] and
+ raise Error, "server error: %s" % [server_error]
+ verifier = sparams["v"].unpack1("m") or
+ raise Error, "server did not send verifier"
+ verifier == server_signature or
+ raise Error, "server verify failed: %p != %p" % [
+ server_signature, verifier
+ ]
+ end
+
+ # See {RFC5802 §7}[https://www.rfc-editor.org/rfc/rfc5802#section-7]
+ # +client-first-message-bare+.
+ def client_first_message_bare
+ @client_first_message_bare ||=
+ format_message(n: gs2_saslname_encode(SASL.saslprep(username)),
+ r: cnonce)
+ end
+
+ # See {RFC5802 §7}[https://www.rfc-editor.org/rfc/rfc5802#section-7]
+ # +client-final-message+.
+ def final_message_with_proof
+ proof = [client_proof].pack("m0")
+ "#{client_final_message_without_proof},p=#{proof}"
+ end
+
+ # See {RFC5802 §7}[https://www.rfc-editor.org/rfc/rfc5802#section-7]
+ # +client-final-message-without-proof+.
+ def client_final_message_without_proof
+ @client_final_message_without_proof ||=
+ format_message(c: [cbind_input].pack("m0"), # channel-binding
+ r: snonce) # nonce
+ end
+
+ # See {RFC5802 §7}[https://www.rfc-editor.org/rfc/rfc5802#section-7]
+ # +cbind-input+.
+ #
+ # >>>
+ # *TODO:* implement channel binding, appending +cbind-data+ here.
+ alias cbind_input gs2_header
+
+ # RFC5802 specifies "that the order of attributes in client or server
+ # messages is fixed, with the exception of extension attributes", but
+ # this parses it simply as a hash, without respect to order. Note that
+ # repeated keys (violating the spec) will use the last value.
+ def parse_challenge(challenge)
+ challenge.split(/,/).to_h {|pair| pair.split(/=/, 2) }
+ rescue ArgumentError
+ raise Error, "unparsable challenge: %p" % [challenge]
+ end
+
+ end
+
+ # Authenticator for the "+SCRAM-SHA-1+" SASL mechanism, defined in
+ # RFC5802[https://tools.ietf.org/html/rfc5802].
+ #
+ # Uses the "SHA-1" digest algorithm from OpenSSL::Digest.
+ #
+ # See ScramAuthenticator.
+ class ScramSHA1Authenticator < ScramAuthenticator
+ DIGEST_NAME = "SHA1"
+ end
+
+ # Authenticator for the "+SCRAM-SHA-256+" SASL mechanism, defined in
+ # RFC7677[https://tools.ietf.org/html/rfc7677].
+ #
+ # Uses the "SHA-256" digest algorithm from OpenSSL::Digest.
+ #
+ # See ScramAuthenticator.
+ class ScramSHA256Authenticator < ScramAuthenticator
+ DIGEST_NAME = "SHA256"
+ end
+
+ end
+ end
+end
diff --git a/test/net/imap/test_imap_authenticators.rb b/test/net/imap/test_imap_authenticators.rb
index 5c3600d05..c0c1e2995 100644
--- a/test/net/imap/test_imap_authenticators.rb
+++ b/test/net/imap/test_imap_authenticators.rb
@@ -78,6 +78,92 @@ def test_oauthbearer_response
)
end
+ # ----------------------
+ # SCRAM-SHA-1
+ # SCRAM-SHA-256
+ # SCRAM-SHA-* (etc)
+ # ----------------------
+
+ def test_scram_sha1_authenticator_matches_mechanism
+ authenticator = Net::IMAP::SASL.authenticator("SCRAM-SHA-1", "user", "pass")
+ assert_kind_of(Net::IMAP::SASL::ScramAuthenticator, authenticator)
+ assert_kind_of(Net::IMAP::SASL::ScramSHA1Authenticator, authenticator)
+ end
+
+ def test_scram_sha256_authenticator_matches_mechanism
+ authenticator = Net::IMAP::SASL.authenticator("SCRAM-SHA-256", "user", "pass")
+ assert_kind_of(Net::IMAP::SASL::ScramAuthenticator, authenticator)
+ assert_kind_of(Net::IMAP::SASL::ScramSHA256Authenticator, authenticator)
+ end
+
+ def scram_sha1(*args, **kwargs, &block)
+ Net::IMAP::SASL.authenticator("SCRAM-SHA-1", *args, **kwargs, &block)
+ end
+
+ def scram_sha256(*args, **kwargs, &block)
+ Net::IMAP::SASL.authenticator("SCRAM-SHA-256", *args, **kwargs, &block)
+ end
+
+ def test_scram_sha1_authenticator
+ authenticator = scram_sha1("user", "pencil",
+ cnonce: "fyko+d2lbbFgONRv9qkxdawL")
+ # n = no channel binding
+ # a = authzid
+ # n = authcid
+ # r = random nonce (client)
+ assert_equal("n,,n=user,r=fyko+d2lbbFgONRv9qkxdawL",
+ authenticator.process(nil))
+ refute authenticator.done?
+ assert_equal(
+ # c = b64 of gs2 header and channel binding data
+ # r = random nonce (client + server)
+ # p = b64 client proof
+ # s = salt
+ # i = iteration count
+ "c=biws," \
+ "r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j," \
+ "p=v0X8v3Bz2T0CJGbJQyF0X+HI4Ts=",
+ authenticator.process(
+ "r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j," \
+ "s=QSXCR+Q6sek8bf92," \
+ "i=4096")
+ )
+ refute authenticator.done?
+ assert_empty authenticator.process("v=rmF9pqV8S7suAoZWja4dJRkFsKQ=")
+ assert authenticator.done?
+ end
+
+ def test_scram_sha256_authenticator
+ authenticator = scram_sha256("user", "pencil",
+ cnonce: "rOprNGfwEbeRWgbNEkqO")
+ # n = no channel binding
+ # a = authzid
+ # n = authcid
+ # r = random nonce (client)
+ assert_equal("n,,n=user,r=rOprNGfwEbeRWgbNEkqO",
+ authenticator.process(nil))
+ refute authenticator.done?
+ assert_equal(
+ # c = b64 of gs2 header and channel binding data
+ # r = random nonce (client + server)
+ # p = b64 client proof
+ # s = salt
+ # i = iteration count
+ "c=biws," \
+ "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0," \
+ "p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=",
+ authenticator.process(
+ "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0," \
+ "s=W22ZaJ0SNY7soEsUEjb6gQ==," \
+ "i=4096")
+ )
+ refute authenticator.done?
+ assert_empty authenticator.process(
+ "v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4="
+ )
+ assert authenticator.done?
+ end
+
# ----------------------
# XOAUTH2
# ----------------------