diff --git a/lib/net/imap/sasl.rb b/lib/net/imap/sasl.rb index 373b036e..d8cf22d6 100644 --- a/lib/net/imap/sasl.rb +++ b/lib/net/imap/sasl.rb @@ -142,6 +142,7 @@ def initialize(response, message = "authentication ended prematurely") autoload :Authenticators, "#{sasl_dir}/authenticators" autoload :GS2Header, "#{sasl_dir}/gs2_header" autoload :ScramAlgorithm, "#{sasl_dir}/scram_algorithm" + autoload :ScramCache, "#{sasl_dir}/scram_cache" autoload :AnonymousAuthenticator, "#{sasl_dir}/anonymous_authenticator" autoload :ExternalAuthenticator, "#{sasl_dir}/external_authenticator" diff --git a/lib/net/imap/sasl/scram_authenticator.rb b/lib/net/imap/sasl/scram_authenticator.rb index 95852811..07e314f8 100644 --- a/lib/net/imap/sasl/scram_authenticator.rb +++ b/lib/net/imap/sasl/scram_authenticator.rb @@ -5,6 +5,7 @@ require_relative "gs2_header" require_relative "scram_algorithm" +require_relative "scram_cache" module Net class IMAP @@ -50,9 +51,22 @@ module SASL # # === Caching SCRAM secrets # - # Caching of salted_password, client_key, stored_key, and server_key - # is not supported yet. + # The values for salted_password, client_key, and server_key are stored in + # #cache, a SASL::ScramCache object. This object can be saved and re-used + # across multiple authentication exchanges. When the #salt and #iteration + # are unchanged, the stored keys will be reused. When they change, the + # cache object is updated with the new values. # + # **NOTE:** The cache object must be handled with the same level of + # caution as the password itself. For example, it should always + # be encrypted at rest. + # + # When +cache+ contains the client and server keys (or the salted + # password), +password+ is optional. But authentication will fail if + # #salt or #iterations change and #password hasn't been provided. + # + # Note that SASL::ScramCache is not thread-safe. Concurrent + # authentications should dup or clone the cache object. class ScramAuthenticator include GS2Header include ScramAlgorithm @@ -73,6 +87,7 @@ class ScramAuthenticator # # #username - An alias for #authcid. # * #password ― Password or passphrase associated with this #username. + # * _optional_ #cache - A pre-existing SASL::ScramCache object. # * _optional_ #authzid ― Alternate identity to act as or on behalf of. # * _optional_ #min_iterations - Overrides the default value (4096). # * _optional_ #max_iterations - Overrides the default value (2³¹ - 1). @@ -82,6 +97,13 @@ class ScramAuthenticator # *NOTE:* It is the user's responsibility to enforce minimum # and maximum iteration counts that are appropriate for their security # context. + # + # === Caching salted credentials + # + # When +cache+ contains the client and server keys (or the salted + # password), +password+ is optional. + # + # See ScramAuthenticator@Caching+SCRAM+secrets and SASL::ScramCache. def initialize(username_arg = nil, password_arg = nil, authcid: nil, username: nil, authzid: nil, @@ -89,10 +111,14 @@ def initialize(username_arg = nil, password_arg = nil, min_iterations: 4096, # see both RFC5802 and RFC7677 max_iterations: 2**31 - 1, # max int32 cnonce: nil, # must only be set in tests + cache: ScramCache.new, **options) @username = username || username_arg || authcid or raise ArgumentError, "missing username (authcid)" - @password = password || secret || password_arg or + cache => ScramCache + @cache = cache + @password = password || secret || password_arg + @password || @cache.sufficient? or raise ArgumentError, "missing password" @authzid = authzid @@ -110,9 +136,6 @@ def initialize(username_arg = nil, password_arg = nil, @server_first_message = @snonce = @salt = @iterations = nil @server_error = nil - # Memoized after @salt and @iterations have been sent. - @salted_password = @client_key = @server_key = nil - # These values are created and cached in response to server challenges @client_first_message_bare = nil @client_final_message_without_proof = nil @@ -197,20 +220,34 @@ def initialize(username_arg = nil, password_arg = nil, # The iteration count for the selected hash function and user attr_reader :iterations + # Caches salted_password, client_key, and server_key, based on a + # specific #salt and #iterations. + # + # See SASL::ScramCache and ScramAuthenticator@Caching+SCRAM+secrets. + attr_reader :cache + # 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 - # Memoized ScramAlgorithm#salted_password (needs #salt and #iterations) - def salted_password = @salted_password ||= compute_salted { super } + # Cached value for ScramAlgorithm#salted_password. + # Requires +salt+ and +iterations+, from the server. + def salted_password + salted_cache_read(:salted_password) { + password or raise Error, "invalid cache: salt or iteration changed" + super + } + end - # Memoized ScramAlgorithm#client_key (needs #salt and #iterations) - def client_key = @client_key ||= compute_salted { super } + # Cached value for ScramAlgorithm#client_key. + # Requires +salt+ and +iterations+, from the server. + def client_key = salted_cache_read(:client_key) { super } - # Memoized ScramAlgorithm#server_key (needs #salt and #iterations) - def server_key = @server_key ||= compute_salted { super } + # Cached value for ScramAlgorithm#server_key. + # Requires +salt+ and +iterations+, from the server. + def server_key = salted_cache_read(:server_key) { super } # Returns a new OpenSSL::Digest object, set to the appropriate hash # function for the chosen mechanism. @@ -251,11 +288,8 @@ def done?; @state == :done end private - # Checks for +salt+ and +iterations+ before yielding - def compute_salted - salt in String or raise Error, "unknown salt" - iterations in Integer or raise Error, "unknown iterations" - yield + def salted_cache_read(name) + cache.read(name, salt:, iterations:) { yield } end # Need to store this for auth_message diff --git a/lib/net/imap/sasl/scram_cache.rb b/lib/net/imap/sasl/scram_cache.rb new file mode 100644 index 00000000..2b74748f --- /dev/null +++ b/lib/net/imap/sasl/scram_cache.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +module Net + class IMAP + module SASL + + # Caches salted_password, client_key, and server_key for + # ScramAuthenticator, based on a specific #salt and #iterations. + # + # **NOTE:** The cache object must be handled with the same level of + # caution as the password itself. For example, it should always + # be encrypted at rest. + # + # The server will most likely advertise the same +salt+ and +iterations+ + # upon reauthentication, so +client_key+ and +server_key+ (or just + # +salted_password+) can usually replace the +password+ parameter to + # ScramAuthenticator. + # + # Note that #read is not thread-safe. Concurrent authentications + # should dup or clone the cache object. + ScramCache = Struct.new( + :salt, :iterations, # cache validity + :salted_password, # sufficient to generate keys + :client_key, :server_key, # sufficient to replace password + keyword_init: true + ) do + # Returns whether the cache is able to be used as credentials, without + # being recomputed from the password, assuming #salt and #iterations are + # unchanged. + def sufficient? + salt && iterations && (client_key && server_key || salted_password) + end + + # Returns whether +salt+ and +iterations+ match cached values. + def valid?(salt:, iterations:) + salt in String or raise Error, "unknown salt" + iterations in Integer or raise Error, "unknown iterations" + self.salt == salt && self.iterations == iterations + end + + # Reset cached values when +salt+ and +iterations+ do not match. + def validate!(**) = valid?(**) || reset(**) + + # After validating +salt+ and +iterations+, either returns the cached + # value for +name+ or yields to recompute and cache +name+. + def read(name, **) + raise ArgumentError, "missing required block" unless block_given? + validate!(**) + self[name] ||= yield + end + + # Reset #salt, #iterations, and all cached fields. + def reset(salt: nil, iterations: nil) + {salt:, iterations:} => {salt: String, iterations: Integer} | + {salt: nil, iterations: nil } + self.salted_password = self.client_key = self.server_key = nil + self.salt = salt + self.iterations = iterations + self + end + + # Returns a string representation with the cached secrets filtered out. + def inspect + format "#", self.class, members + .map { [_1, filtered_inspect(_1)].join("=") } + .join(", ") + end + alias to_s inspect + + # Pretty prints a representation with the cached secrets filtered out. + def pretty_print(q) + q.group(1, sprintf("#') { + q.seplist(PP.mcall(self, Struct, :members), lambda { q.text "," }) {|member| + q.breakable + q.text member.to_s + q.text '=' + q.group(1) { + q.breakable '' + if secret_member?(member) + q.text filtered_inspect(member) + else + q.pp self[member] + end + } + } + } + end + + private + + def secret_member?(member) + member in :salted_password | :client_key | :server_key + end + + def filtered_inspect(member) + value = self[member] + return value.inspect unless secret_member?(member) + case value + in String then format "#", value.bytesize + in nil then "nil" + else format "#", value.class + end + end + + end + + end + end +end diff --git a/test/net/imap/sasl/test_scram_cache.rb b/test/net/imap/sasl/test_scram_cache.rb new file mode 100644 index 00000000..a5e85ec8 --- /dev/null +++ b/test/net/imap/sasl/test_scram_cache.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +require "net/imap" +require "test/unit" + +class SASLScamCacheTest < Net::IMAP::TestCase + SASL = Net::IMAP::SASL + + test "#sufficient?" do + cache = SASL::ScramCache.new + refute cache.sufficient? + cache.salt = "salt" + cache.iterations = 5000 + refute cache.sufficient? + cache.salted_password = "the-salted-password" + assert cache.sufficient? + + cache.salted_password = nil + cache.client_key = "the client key" + refute cache.sufficient? + cache.server_key = "the server key" + assert cache.sufficient? + end + + test "#validate!" do + cache = SASL::ScramCache.new + assert_raise(SASL::Error) { cache.validate!(salt: nil, iterations: nil) } + assert_raise(SASL::Error) { cache.validate!(salt: "s", iterations: nil) } + assert_raise(SASL::Error) { cache.validate!(salt: nil, iterations: 9999) } + cache.salt = "s" + cache.iterations = 9999 + cache.salted_password = "salted" + cache.client_key = "ckey" + cache.server_key = "skey" + cache.validate!(salt: "different", iterations: 99_999) + assert_equal "different", cache.salt + assert_equal 99_999, cache.iterations + assert_nil cache.salted_password + assert_nil cache.client_key + assert_nil cache.server_key + end + + test "#read" do + cache = SASL::ScramCache.new + assert_raise(ArgumentError) { cache.read(:client_key) } + assert_raise(ArgumentError) { cache.read(:client_key) { } } + salt = iterations = nil + assert_raise(SASL::Error) { cache.read(:client_key, salt:, iterations:) { } } + + salt, iterations = "salt1", 5000 + assert_equal "ck1", cache.read(:client_key, salt:, iterations:) { "ck1" } + assert_equal "ck1", cache.client_key + assert_equal "salt1", cache.salt + assert_equal 5000, cache.iterations + assert_equal "ck1", cache.read(:client_key, salt:, iterations:) { "update" } + assert_equal "ck1", cache.client_key + + salt, iterations = "salt2", 9999 + assert_equal "ck2", cache.read(:client_key, salt:, iterations:) { "ck2" } + assert_equal "ck2", cache.client_key + assert_equal "salt2", cache.salt + assert_equal 9999, cache.iterations + + assert_equal "sk", cache.read(:server_key, salt:, iterations:) { "sk" } + assert cache.sufficient? + end + + %w[inspect to_s].each do |method| + test "##{method}" do + cache = SASL::ScramCache.new + assert_equal( + "#", + cache.public_send(method) + ) + cache.salt = salt = "saltysaltsalt" + cache.iterations = 100_000 + assert_equal( + "#", + cache.public_send(method) + ) + cache.salted_password = "this should be filtered!!" + assert_equal( + "#, client_key=nil, server_key=nil>", + cache.public_send(method) + ) + cache.client_key = "filter!" + cache.server_key = "this!" + assert_equal( + "#, " \ + "client_key=#, " \ + "server_key=#>", + cache.public_send(method) + ) + cache.salted_password = nil + assert_equal( + "#, " \ + "server_key=#>", + cache.public_send(method) + ) + end + end + + test "#pretty_print" do + width = 80 + cache = SASL::ScramCache.new + PP.pp cache, (output = String.new), width + assert_equal(<<~PP, output) + # + PP + cache.salt = salt = "saltosaltersaltin" + cache.iterations = 100_000 + PP.pp cache, (output = String.new), width + assert_equal(<<~PP, output) + # + PP + cache.salted_password = "this should be filtered!!" + cache.client_key = Object.new + cache.server_key = 123.5 + PP.pp cache, (output = String.new), width + assert_equal(<<~PP, output) + #, + client_key=#, + server_key=#> + PP + end + +end diff --git a/test/net/imap/test_authenticators.rb b/test/net/imap/test_authenticators.rb index d7be17c7..d731e068 100644 --- a/test/net/imap/test_authenticators.rb +++ b/test/net/imap/test_authenticators.rb @@ -211,6 +211,94 @@ def test_scram_sha256_authenticator "v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=" ) assert authenticator.done? + # check cache values + assert_kind_of Net::IMAP::SASL::ScramCache, authenticator.cache + assert_equal "xKSVEDI6tPlSysH6mUQZOeeOp01r6B3fcJbodRPcYV0=".unpack1("m"), + authenticator.cache.salted_password + assert_equal "pg/JI9Z+hkSpLRa5btpe9GVrDHJcSEN0viVTVXaZbos=".unpack1("m"), + authenticator.cache.client_key + assert_equal "wfPLwcE6nTWhTAmQ7tl2KeoiWGPlZqQxSrmfPwDl2dU=".unpack1("m"), + authenticator.cache.server_key + end + + def test_scram_sha256_with_cached_salted_password + cache = Net::IMAP::SASL::ScramCache[ + salt: "W22ZaJ0SNY7soEsUEjb6gQ==".unpack1("m"), + iterations: 4096, + salted_password: "xKSVEDI6tPlSysH6mUQZOeeOp01r6B3fcJbodRPcYV0=".unpack1("m") + ] + authenticator = scram_sha256("user", cache:, 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? + # check cache values + assert_same cache, authenticator.cache + assert_equal "pg/JI9Z+hkSpLRa5btpe9GVrDHJcSEN0viVTVXaZbos=".unpack1("m"), + authenticator.cache.client_key + assert_equal "wfPLwcE6nTWhTAmQ7tl2KeoiWGPlZqQxSrmfPwDl2dU=".unpack1("m"), + authenticator.cache.server_key + end + + def test_scram_sha256_with_cached_client_and_server_keys + cache = Net::IMAP::SASL::ScramCache[ + salt: "W22ZaJ0SNY7soEsUEjb6gQ==".unpack1("m"), + iterations: 4096, + client_key: "pg/JI9Z+hkSpLRa5btpe9GVrDHJcSEN0viVTVXaZbos=".unpack1("m"), + server_key: "wfPLwcE6nTWhTAmQ7tl2KeoiWGPlZqQxSrmfPwDl2dU=".unpack1("m"), + ] + authenticator = scram_sha256("user", cache:, 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? + # check cache values + assert_same cache, authenticator.cache + assert_nil authenticator.cache.salted_password end def test_scram_min_iterations