diff --git a/lib/net/imap.rb b/lib/net/imap.rb index 07d1b5bb..eabc7a2a 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -976,6 +976,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 b1171ed3..e7a3af2f 100644 --- a/lib/net/imap/authenticators.rb +++ b/lib/net/imap/authenticators.rb @@ -67,6 +67,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 # ----------------------