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