diff --git a/lib/net/imap.rb b/lib/net/imap.rb
index d6d62cec..cdc74ad7 100644
--- a/lib/net/imap.rb
+++ b/lib/net/imap.rb
@@ -1007,6 +1007,17 @@ def starttls(options = {}, verify = true)
#
# 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 9fc3666c..172871be 100644
--- a/lib/net/imap/sasl.rb
+++ b/lib/net/imap/sasl.rb
@@ -32,6 +32,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.
#
@@ -69,8 +80,13 @@ 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 :ScramAuthenticator, "#{sasl_dir}/scram_authenticator"
autoload :PlainAuthenticator, "#{sasl_dir}/plain_authenticator"
+ autoload :ScramSHA1Authenticator, "#{sasl_dir}/scram_sha1_authenticator"
+ autoload :ScramSHA256Authenticator, "#{sasl_dir}/scram_sha256_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 ef193df4..47ace176 100644
--- a/lib/net/imap/sasl/authenticators.rb
+++ b/lib/net/imap/sasl/authenticators.rb
@@ -34,6 +34,8 @@ def initialize(use_defaults: false)
@authenticators = {}
if use_defaults
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
new file mode 100644
index 00000000..75738c36
--- /dev/null
+++ b/lib/net/imap/sasl/gs2_header.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+module Net
+ class IMAP < Protocol
+ module SASL
+
+ # Originally defined for the GS2 mechanism family in
+ # RFC5801[https://tools.ietf.org/html/rfc5801],
+ # several different mechanisms start with a GS2 header:
+ # * +GS2-*+ --- RFC5801[https://tools.ietf.org/html/rfc5801]
+ # * +SCRAM-*+ --- RFC5802[https://tools.ietf.org/html/rfc5802],
+ # see 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]
+ # * +OAUTHBEARER+ --- RFC7628[https://tools.ietf.org/html/rfc7628]
+ #
+ # Classes that include this module must implement +#authzid+.
+ module GS2Header
+ NO_NULL_CHARS = /\A[^\x00]+\z/u.freeze # :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.freeze
+
+ # The {RFC5801 §4}[https://www.rfc-editor.org/rfc/rfc5801#section-4]
+ # +gs2-header+, which prefixes the #initial_client_response.
+ #
+ # >>>
+ # 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,+".
+ 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.
+ def gs2_saslname_encode(str)
+ str = str.encode("UTF-8")
+ # Regexp#match raises "invalid byte sequence" for invalid UTF-8
+ NO_NULL_CHARS.match str or
+ raise ArgumentError, "invalid saslname: %p" % [str]
+ str
+ .gsub(?=, "=3D")
+ .gsub(?,, "=2C")
+ end
+
+ end
+ end
+ end
+end
diff --git a/lib/net/imap/sasl/scram_algorithm.rb b/lib/net/imap/sasl/scram_algorithm.rb
new file mode 100644
index 00000000..efe7201e
--- /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 00000000..4e7a66fd
--- /dev/null
+++ b/lib/net/imap/sasl/scram_authenticator.rb
@@ -0,0 +1,335 @@
+# 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 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+).
+ #
+ # === 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_without_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.
+ #
+ # 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
+
+ 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
+
+ ##
+ # :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, password_arg = nil, authzid_arg = nil,
+ authcid: nil, username: nil, password: nil, authzid: nil,
+ min_iterations: 4096, # see both RFC5802 and RFC7677
+ cnonce: nil, # must only be set in tests
+ **options)
+ @authcid = authcid || username || username_arg
+ @password = password || password_arg
+ @authzid = authzid || authzid_arg
+ @authcid or raise ArgumentError, "missing authcid (username)"
+ @password or raise ArgumentError, "missing password"
+ [authcid, username, username_arg].compact.count == 1 or
+ raise ArgumentError, "conflicting values for authcid (username)"
+ [password, password_arg].compact.count == 1 or
+ raise ArgumentError, "conflicting values for password (username)"
+ [authzid, authzid_arg].compact.count <= 1 or
+ raise ArgumentError, "conflicting values for authzid (username)"
+
+ @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.
+ attr_reader :authcid
+ alias username authcid
+
+ ##
+ # :call-seq: password -> string or nil
+ #
+ # A password or passphrase that matches the #authcid.
+ attr_reader :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"
+ #
+ attr_reader :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_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
+
+ 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 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 parse_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
+
+ # 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
+ end
+ end
+end
diff --git a/lib/net/imap/sasl/scram_sha1_authenticator.rb b/lib/net/imap/sasl/scram_sha1_authenticator.rb
new file mode 100644
index 00000000..a6ccb580
--- /dev/null
+++ b/lib/net/imap/sasl/scram_sha1_authenticator.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Net
+ class IMAP
+ module SASL
+
+ # 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_algorithm "SHA-1"
+ end
+
+ end
+ end
+end
diff --git a/lib/net/imap/sasl/scram_sha256_authenticator.rb b/lib/net/imap/sasl/scram_sha256_authenticator.rb
new file mode 100644
index 00000000..0e8b57c4
--- /dev/null
+++ b/lib/net/imap/sasl/scram_sha256_authenticator.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Net
+ class IMAP
+ module SASL
+
+ # 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_algorithm "SHA-256"
+ end
+
+ end
+ end
+end
diff --git a/test/net/imap/test_imap_authenticators.rb b/test/net/imap/test_imap_authenticators.rb
index 605eaace..59f66011 100644
--- a/test/net/imap/test_imap_authenticators.rb
+++ b/test/net/imap/test_imap_authenticators.rb
@@ -56,6 +56,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::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
# ----------------------