From 617acb3d257eeb0eaf34c3bdea4ce0628969d523 Mon Sep 17 00:00:00 2001 From: nick evans Date: Fri, 18 Nov 2022 13:40:41 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92=20Add=20SASL=20SCRAM-SHA-*=20mecha?= =?UTF-8?q?nisms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loosely based on the implementation by @singpolyma at https://github.com/nevans/net-sasl/pull/5 New authenticators for any digest algorithms supported can be added by subclassing ScramAuthenticator and adding a DIGEST_NAME constant (and then registering with an Authenticators registry). Co-authored-by: Stephen Paul Weber --- lib/net/imap.rb | 11 + lib/net/imap/sasl.rb | 15 ++ lib/net/imap/sasl/authenticators.rb | 2 + lib/net/imap/sasl/gs2_header.rb | 1 + lib/net/imap/sasl/scram_algorithm.rb | 58 +++++ lib/net/imap/sasl/scram_authenticator.rb | 278 ++++++++++++++++++++++ test/net/imap/test_imap_authenticators.rb | 86 +++++++ 7 files changed, 451 insertions(+) create mode 100644 lib/net/imap/sasl/scram_algorithm.rb create mode 100644 lib/net/imap/sasl/scram_authenticator.rb 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 # ----------------------