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 # ----------------------