diff --git a/lib/net/imap.rb b/lib/net/imap.rb
index f726cd72..e3601228 100644
--- a/lib/net/imap.rb
+++ b/lib/net/imap.rb
@@ -987,6 +987,15 @@ def starttls(options = {}, verify = true)
# +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
diff --git a/lib/net/imap/authenticators.rb b/lib/net/imap/authenticators.rb
index 17008ba9..135f819f 100644
--- a/lib/net/imap/authenticators.rb
+++ b/lib/net/imap/authenticators.rb
@@ -71,6 +71,7 @@ def authenticators
require_relative "sasl/external_authenticator"
require_relative "sasl/oauthbearer_authenticator"
require_relative "sasl/plain_authenticator"
+require_relative "sasl/scram_authenticator"
require_relative "sasl/xoauth2_authenticator"
# deprecated
diff --git a/lib/net/imap/sasl/authenticator.rb b/lib/net/imap/sasl/authenticator.rb
index 572040c1..ee64513c 100644
--- a/lib/net/imap/sasl/authenticator.rb
+++ b/lib/net/imap/sasl/authenticator.rb
@@ -22,6 +22,15 @@ module SASL
# +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
@@ -102,7 +111,6 @@ module SASL
# * +scram_sha1_salted_passwords+, +scram_sha256_salted_password+ ---
# Salted password(s) (with salt and iteration count) for the +SCRAM-*+
# mechanism family. [salt, iterations, pbkdf2_hmac] tuple.
- # (not implemented yet...)
# * +passcode+ --- passcode for SecurID 2FA (not implemented)
# * +pin+ --- Personal Identification number, e.g. for SecurID 2FA
# (not implemented)
diff --git a/lib/net/imap/sasl/scram_algorithm.rb b/lib/net/imap/sasl/scram_algorithm.rb
new file mode 100644
index 00000000..d74adbdf
--- /dev/null
+++ b/lib/net/imap/sasl/scram_algorithm.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+module Net
+ class IMAP
+ module SASL
+
+ # For method descriptions, see {RFC5802
+ # §2}[https://www.rfc-editor.org/rfc/rfc5802#section-2] and {RFC5802
+ # §3}[https://www.rfc-editor.org/rfc/rfc5802#section-3].
+ #
+ # Expects:
+ # * #Hi, #H, and #HMAC use:
+ # * +#digest+ --- an OpenSSL::Digest.
+ # * #salted_password uses:
+ # * +#salt+ and +#iterations+ --- the server's values for this user
+ # * +#password+
+ # * #auth_message is built from:
+ # * +#client_first_message_bare+ --- contains +#cnonce+
+ # * +#server_first_message+ --- contains +#snonce+
+ # * +#client_final_message_no_proof+ --- contains +#snonce+
+ 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_no_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 00000000..54689b04
--- /dev/null
+++ b/lib/net/imap/sasl/scram_authenticator.rb
@@ -0,0 +1,348 @@
+# 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+ --- ScramAuthenticator::SHA1
+ # * +SCRAM-SHA-256+ --- ScramAuthenticator::SHA256
+ #
+ # >>>
+ # *Note:* The SCRAM-*-PLUS mechanisms and channel binding
+ # are not supported yet.
+ #
+ # New +SCRAM-*+ mechanisms can easily be added for any hash function
+ # supported by OpenSSL::Digest.
+ #
+ # === 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+).
+ #
+ # 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 AuthenticationFailure for the
+ # server_final_message, then server_error may contain error details.
+ #
+ # === Saved message parameters
+ #
+ # ==== Client messages
+ #
+ # As client messages are generated and sent, they are validated and saved
+ # as #client_first_message_bare, #client_first_message,
+ # #client_final_message_no_proof, and #client_final_message. Some message
+ # attributes are also saved, such as #cnonce. 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. The message strings themselves are also saved as
+ # #server_first_message, #server_final_message, and #server_extra_message.
+ #
+ # === Caching SCRAM secrets
+ #
+ # TODO: enable caching of salted_password, client_key, stored_key,
+ # server_key.
+ class ScramAuthenticator < Authenticator
+ include GS2Header
+ include ScramAlgorithm
+
+ def self.digest_algorithm(name)
+ mech = "SCRAM-#{name}"
+ ossl = name.delete("-")
+ singleton_class.class_eval do
+ define_method(:mechanism_name) { mech }
+ define_method(:digest) { OpenSSL::Digest.new ossl }
+ end
+ define_method(:digest) { OpenSSL::Digest.new ossl }
+ 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 SHA1 < ScramAuthenticator
+ digest_algorithm "SHA-1"
+ register Net::IMAP
+ 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 SHA256 < ScramAuthenticator
+ digest_algorithm "SHA-256"
+ register Net::IMAP
+ end
+
+ ##
+ # :call-seq:
+ # new(username, password, authzid = nil, **) -> auth_ctx
+ # new(authcid:, password:, authzid: nil, **) -> auth_ctx
+ # new(**) {|propname, auth_ctx| propval } -> 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.
+ #
+ # === Properties
+ #
+ # * #authcid ― Identity whose #password is used. Aliased as #username.
+ # * #password ― Password or passphrase associated with this #authcid.
+ # * #authzid ― Alternate identity to act as or on behalf of. Optional.
+ # * Not implemented yet: +scram_sha1_salted_passwords+,
+ # +scram_sha256_salted_passwords+, +scram_sha1_salted_password+,
+ # +scram_sha256_salted_password+ --- Cached salted password(s)
+ # tuple(s) (combined with salt and iteration count): [salt,
+ # iterations, pbkdf2_hmac]
+ #
+ # See the documentation on each property method for more details.
+ #
+ # +authcid+, +password+, and +authzid+ may be sent as either positional
+ # or keyword arguments. See Net::IMAP::SASL::Authenticator@Properties
+ # for a detailed description of property assignment, lazy loading, and
+ # callbacks.
+ #
+ def initialize(username_arg = nil, passwd_arg = nil, authzid_arg = nil,
+ authcid: nil, username: nil, password: nil, authzid: nil,
+ min_iterations: 1,
+ cnonce: nil, # must only be set in tests
+ **options)
+ super
+ propinit :authcid, authcid, username, username_arg, required: true
+ propinit :password, password, passwd_arg, required: true
+ propinit :authzid, authzid, authzid_arg
+
+ @min_iterations = Integer min_iterations
+ @min_iterations.positive? or
+ raise ArgumentError, "min_iterations must be positive"
+ @cnonce = cnonce || SecureRandom.base64(32)
+ end
+
+ ##
+ # :call-seq: authcid -> string or nil
+ #
+ # Authentication identity: the identity that matches the #password.
+ #
+ # RFC-2831[https://tools.ietf.org/html/rfc2831] uses the term +username+.
+ # "Authentication identity" is the generic term used by
+ # RFC-4422[https://tools.ietf.org/html/rfc4422].
+ # RFC-4616[https://tools.ietf.org/html/rfc4616] and many later RFCs
+ # abbreviate to +authcid+. #username is available as an alias for
+ # #authcid, but only :authcid will be sent to callbacks.
+ property :authcid
+ alias username authcid
+
+ ##
+ # :call-seq: password -> string or nil
+ #
+ # A password or passphrase that matches the #authcid.
+ property :password
+
+ ##
+ # :call-seq: authzid -> string or nil
+ #
+ # 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. 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.
+ #
+ # For example, an administrator or superuser might take on another role:
+ #
+ # imap.authenticate "PLAIN", "root", ->{passwd}, authzid: "user"
+ #
+ property :authzid
+
+ # The minimal allowed iteration count. Lower #iterations will raise an
+ # AuthenticationFailure.
+ 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
+
+ # The first message sent to the server
+ attr_reader :client_first_message
+
+ # The final message sent to the server
+ attr_reader :client_final_message
+
+ # The server-sent parameters from its first message
+ attr_reader :server_first_message
+
+ # The server-sent parameters from its final message
+ attr_reader :server_final_message
+
+ # An unexpected server challenge, either sent before the
+ # initial_client_response and ignored, or sent after the
+ # server_final_message and raised an error.
+ attr_reader :server_extra_message
+
+ # The server verifier, which must equal the locally computed server
+ # signature.
+ attr_reader :verifier
+
+ # 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
+
+ # Has the initial_client_response been sent yet?
+ alias sent_first? client_first_message
+
+ # Has the final_client_message been sent yet?
+ alias sent_final? client_final_message
+
+ # Has the first server response been received yet?
+ alias recv_first? server_first_message
+
+ # Has the final server response been received yet?
+ alias recv_final? server_final_message
+
+ # Returns a new OpenSSL::Digest object, set to the appropriate hash
+ # function for the chosen mechanism.
+ #
+ # The class's +Digest+ constant must be set to an OpenSSL::Digest
+ # class.
+ def digest; raise NotImplementedError, "defined in subclasses" end
+
+ # Is the authentication exchange complete?
+ #
+ # If false, another server continuation is required.
+ def done?; sent_final? && recv_final? end
+
+ # responds to the server's challenges
+ def process(challenge)
+ if !sent_first?
+ @server_extra_message = challenge
+ @client_first_message = initial_client_response
+ elsif !recv_first?
+ @server_first_message = challenge
+ parse_server_first_message
+ @client_final_message = final_message_with_proof
+ elsif !recv_final?
+ @server_final_message = challenge
+ parse_server_final_message
+ ""
+ else
+ @server_extra_message = challenge
+ raise AuthenticationFailure, "server sent after complete, %p" % [
+ server_extra_message,
+ ]
+ end
+ 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
+
+ # 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_no_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_no_proof
+ @client_final_message_no_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
+
+ private
+
+ def format_message(hash) hash.map { _1.join("=") }.join(",") end
+
+ def parse_server_first_message
+ sparams = parse_challenge server_first_message
+ @snonce = sparams["r"] or
+ raise AuthenticationFailure, "server did not send nonce"
+ @salt = sparams["s"]&.unpack1("m") or
+ raise AuthenticationFailure, "server did not send salt"
+ @iterations = sparams["i"]&.then {|i| Integer i } or
+ raise AuthenticationFailure, "server did not send iteration count"
+ min_iterations <= iterations or
+ raise AuthenticationFailure, "too few iterations: %d" % [iterations]
+ mext = sparams["m"] and
+ raise AuthenticationFailure, "mandatory extension: %p" % [mext]
+ snonce.start_with? cnonce or
+ raise AuthenticationFailure, "invalid server nonce"
+ end
+
+ def parse_server_final_message
+ sparams = parse_challenge server_final_message
+ @server_error = sparams["e"] and
+ raise AuthenticationFailure, "server error: %s" % [server_error]
+ @verifier = sparams["v"].unpack1("m") or
+ raise AuthenticationFailure, "server did not send verifier"
+ verifier == server_signature or
+ raise AuthenticationFailure, "server verify failed: %p != %p" % [
+ server_signature, verifier
+ ]
+ end
+
+ # 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 AuthenticationFailure, "unparsable challenge: %p" % [challenge]
+ end
+
+ end
+ end
+ end
+end
diff --git a/test/net/imap/sasl/test_authenticators.rb b/test/net/imap/sasl/test_authenticators.rb
index 2623734f..0bacf092 100644
--- a/test/net/imap/sasl/test_authenticators.rb
+++ b/test/net/imap/sasl/test_authenticators.rb
@@ -31,6 +31,92 @@ def test_plain_no_null_chars
assert_raise(ArgumentError) { plain("u", "p", authzid: "bad\0authz") }
end
+ # ----------------------
+ # SCRAM-SHA-1
+ # SCRAM-SHA-256
+ # SCRAM-SHA-* (etc)
+ # ----------------------
+
+ def test_scram_sha1_authenticator_matches_mechanism
+ authenticator = Net::IMAP.authenticator("SCRAM-SHA-1", "user", "pass")
+ assert_kind_of(Net::IMAP::SASL::ScramAuthenticator, authenticator)
+ assert_kind_of(Net::IMAP::SASL::ScramAuthenticator::SHA1, authenticator)
+ end
+
+ def test_scram_sha256_authenticator_matches_mechanism
+ authenticator = Net::IMAP.authenticator("SCRAM-SHA-256", "user", "pass")
+ assert_kind_of(Net::IMAP::SASL::ScramAuthenticator, authenticator)
+ assert_kind_of(Net::IMAP::SASL::ScramAuthenticator::SHA256, authenticator)
+ end
+
+ def scram_sha1(*args, **kwargs, &block)
+ Net::IMAP.authenticator("SCRAM-SHA-1", *args, **kwargs, &block)
+ end
+
+ def scram_sha256(*args, **kwargs, &block)
+ Net::IMAP.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
+
# ----------------------
# OAUTHBEARER
# ----------------------