Skip to content

Commit 483c3a8

Browse files
committed
🔒️ Add SASL::ScramCache for salted SCRAM keys
Adds `SASL::ScramCache`, a `SASL::ScramAuthenticator#cache` attr, and a `cache` keyword parameter to `SASL::ScramAuthenticator#initialize`. This avoids expensive re-hashing that was done during a single authentication exchange, but can also be re-used across multiple authentication exchanges.
1 parent 6378558 commit 483c3a8

5 files changed

Lines changed: 272 additions & 17 deletions

File tree

lib/net/imap/sasl.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ def initialize(response, message = "authentication ended prematurely")
142142
autoload :Authenticators, "#{sasl_dir}/authenticators"
143143
autoload :GS2Header, "#{sasl_dir}/gs2_header"
144144
autoload :ScramAlgorithm, "#{sasl_dir}/scram_algorithm"
145+
autoload :ScramCache, "#{sasl_dir}/scram_cache"
145146

146147
autoload :AnonymousAuthenticator, "#{sasl_dir}/anonymous_authenticator"
147148
autoload :ExternalAuthenticator, "#{sasl_dir}/external_authenticator"

lib/net/imap/sasl/scram_authenticator.rb

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
require_relative "gs2_header"
77
require_relative "scram_algorithm"
8+
require_relative "scram_cache"
89

910
module Net
1011
class IMAP
@@ -50,9 +51,22 @@ module SASL
5051
#
5152
# === Caching SCRAM secrets
5253
#
53-
# <em>Caching of salted_password, client_key, stored_key, and server_key
54-
# is not supported yet.</em>
54+
# The values for salted_password, client_key, and server_key are stored in
55+
# #cache, a SASL::ScramCache object. This object can be saved and re-used
56+
# across multiple authentication exchanges. When the #salt and #iteration
57+
# are unchanged, the stored keys will be reused. When they change, the
58+
# cache object is updated with the new values.
5559
#
60+
# **NOTE:** <em>The cache object must be handled with the same level of
61+
# caution as the password itself.</em> For example, it should always
62+
# be encrypted at rest.
63+
#
64+
# When +cache+ contains the client and server keys (or the salted
65+
# password), +password+ is optional. But authentication will fail if
66+
# #salt or #iterations change and #password hasn't been provided.
67+
#
68+
# Note that SASL::ScramCache is <em>not thread-safe</em>. Concurrent
69+
# authentications should dup or clone the cache object.
5670
class ScramAuthenticator
5771
include GS2Header
5872
include ScramAlgorithm
@@ -73,20 +87,32 @@ class ScramAuthenticator
7387
#
7488
# #username - An alias for #authcid.
7589
# * #password ― Password or passphrase associated with this #username.
90+
# * _optional_ #cache - A pre-existing SASL::ScramCache object.
7691
# * _optional_ #authzid ― Alternate identity to act as or on behalf of.
7792
# * _optional_ #min_iterations - Overrides the default value (4096).
7893
#
7994
# Any other keyword parameters are quietly ignored.
95+
#
96+
# === Caching salted credentials
97+
#
98+
# When +cache+ contains the client and server keys (or the salted
99+
# password), +password+ is optional.
100+
#
101+
# See ScramAuthenticator@Caching+SCRAM+secrets and SASL::ScramCache.
80102
def initialize(username_arg = nil, password_arg = nil,
81103
authcid: nil, username: nil,
82104
authzid: nil,
83105
password: nil, secret: nil,
84106
min_iterations: 4096, # see both RFC5802 and RFC7677
85107
cnonce: nil, # must only be set in tests
108+
cache: ScramCache.new,
86109
**options)
87110
@username = username || username_arg || authcid or
88111
raise ArgumentError, "missing username (authcid)"
89-
@password = password || secret || password_arg or
112+
cache => ScramCache
113+
@cache = cache
114+
@password = password || secret || password_arg
115+
@password || @cache.sufficient? or
90116
raise ArgumentError, "missing password"
91117
@authzid = authzid
92118

@@ -100,9 +126,6 @@ def initialize(username_arg = nil, password_arg = nil,
100126
@server_first_message = @snonce = @salt = @iterations = nil
101127
@server_error = nil
102128

103-
# Memoized after @salt and @iterations have been sent.
104-
@salted_password = @client_key = @server_key = nil
105-
106129
# These values are created and cached in response to server challenges
107130
@client_first_message_bare = nil
108131
@client_final_message_without_proof = nil
@@ -152,20 +175,34 @@ def initialize(username_arg = nil, password_arg = nil,
152175
# The iteration count for the selected hash function and user
153176
attr_reader :iterations
154177

178+
# Caches salted_password, client_key, and server_key, based on a
179+
# specific #salt and #iterations.
180+
#
181+
# See SASL::ScramCache and ScramAuthenticator@Caching+SCRAM+secrets.
182+
attr_reader :cache
183+
155184
# An error reported by the server during the \SASL exchange.
156185
#
157186
# Does not include errors reported by the protocol, e.g.
158187
# Net::IMAP::NoResponseError.
159188
attr_reader :server_error
160189

161-
# Memoized ScramAlgorithm#salted_password (needs #salt and #iterations)
162-
def salted_password = @salted_password ||= compute_salted { super }
190+
# Cached value for ScramAlgorithm#salted_password.
191+
# Requires +salt+ and +iterations+, from the server.
192+
def salted_password
193+
salted_cache_read(:salted_password) {
194+
password or raise Error, "invalid cache: salt or iteration changed"
195+
super
196+
}
197+
end
163198

164-
# Memoized ScramAlgorithm#client_key (needs #salt and #iterations)
165-
def client_key = @client_key ||= compute_salted { super }
199+
# Cached value for ScramAlgorithm#client_key.
200+
# Requires +salt+ and +iterations+, from the server.
201+
def client_key = salted_cache_read(:client_key) { super }
166202

167-
# Memoized ScramAlgorithm#server_key (needs #salt and #iterations)
168-
def server_key = @server_key ||= compute_salted { super }
203+
# Cached value for ScramAlgorithm#server_key.
204+
# Requires +salt+ and +iterations+, from the server.
205+
def server_key = salted_cache_read(:server_key) { super }
169206

170207
# Returns a new OpenSSL::Digest object, set to the appropriate hash
171208
# function for the chosen mechanism.
@@ -206,11 +243,8 @@ def done?; @state == :done end
206243

207244
private
208245

209-
# Checks for +salt+ and +iterations+ before yielding
210-
def compute_salted
211-
salt in String or raise Error, "unknown salt"
212-
iterations in Integer or raise Error, "unknown iterations"
213-
yield
246+
def salted_cache_read(name)
247+
cache.read(name, salt:, iterations:) { yield }
214248
end
215249

216250
# Need to store this for auth_message

lib/net/imap/sasl/scram_cache.rb

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# frozen_string_literal: true
2+
3+
module Net
4+
class IMAP
5+
module SASL
6+
7+
# Caches salted_password, client_key, and server_key for
8+
# ScramAuthenticator, based on a specific #salt and #iterations.
9+
#
10+
# **NOTE:** <em>The cache object must be handled with the same level of
11+
# caution as the password itself.</em> For example, it should always
12+
# be encrypted at rest.
13+
#
14+
# The server will most likely advertise the same +salt+ and +iterations+
15+
# upon reauthentication, so +client_key+ and +server_key+ (or just
16+
# +salted_password+) can usually replace the +password+ parameter to
17+
# ScramAuthenticator.
18+
#
19+
# Note that #read is <em>not thread-safe</em>. Concurrent authentications
20+
# should dup or clone the cache object.
21+
ScramCache = Struct.new(
22+
:salt, :iterations, # cache validity
23+
:salted_password, # sufficient to generate keys
24+
:client_key, :server_key, # sufficient to replace password
25+
) do
26+
# Returns whether the cache is able to be used as credentials, without
27+
# being recomputed from the password, assuming #salt and #iterations are
28+
# unchanged.
29+
def sufficient?
30+
salt && iterations && (client_key && server_key || salted_password)
31+
end
32+
33+
# Returns whether +salt+ and +iterations+ match cached values.
34+
def valid?(salt:, iterations:)
35+
salt in String or raise Error, "unknown salt"
36+
iterations in Integer or raise Error, "unknown iterations"
37+
self.salt == salt && self.iterations == iterations
38+
end
39+
40+
# Reset cached values when +salt+ and +iterations+ do not match.
41+
def validate!(**) = valid?(**) || reset(**)
42+
43+
# After validating +salt+ and +iterations+, either returns the cached
44+
# value for +name+ or yields to recompute and cache +name+.
45+
def read(name, **)
46+
raise ArgumentError, "missing required block" unless block_given?
47+
validate!(**)
48+
self[name] ||= yield
49+
end
50+
51+
# Reset #salt, #iterations, and all cached fields.
52+
def reset(salt: nil, iterations: nil)
53+
{salt:, iterations:} => {salt: String, iterations: Integer} |
54+
{salt: nil, iterations: nil }
55+
self.salted_password = self.client_key = self.server_key = nil
56+
self.salt = salt
57+
self.iterations = iterations
58+
self
59+
end
60+
end
61+
62+
end
63+
end
64+
end
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# frozen_string_literal: true
2+
3+
require "net/imap"
4+
require "test/unit"
5+
6+
class SASLScamCacheTest < Net::IMAP::TestCase
7+
SASL = Net::IMAP::SASL
8+
9+
test "#sufficient?" do
10+
cache = SASL::ScramCache.new
11+
refute cache.sufficient?
12+
cache.salt = "salt"
13+
cache.iterations = 5000
14+
refute cache.sufficient?
15+
cache.salted_password = "the-salted-password"
16+
assert cache.sufficient?
17+
18+
cache.salted_password = nil
19+
cache.client_key = "the client key"
20+
refute cache.sufficient?
21+
cache.server_key = "the server key"
22+
assert cache.sufficient?
23+
end
24+
25+
test "#validate!" do
26+
cache = SASL::ScramCache.new
27+
assert_raise(SASL::Error) { cache.validate!(salt: nil, iterations: nil) }
28+
assert_raise(SASL::Error) { cache.validate!(salt: "s", iterations: nil) }
29+
assert_raise(SASL::Error) { cache.validate!(salt: nil, iterations: 9999) }
30+
cache.salt = "s"
31+
cache.iterations = 9999
32+
cache.salted_password = "salted"
33+
cache.client_key = "ckey"
34+
cache.server_key = "skey"
35+
cache.validate!(salt: "different", iterations: 99_999)
36+
assert_equal "different", cache.salt
37+
assert_equal 99_999, cache.iterations
38+
assert_nil cache.salted_password
39+
assert_nil cache.client_key
40+
assert_nil cache.server_key
41+
end
42+
43+
test "#read" do
44+
cache = SASL::ScramCache.new
45+
assert_raise(ArgumentError) { cache.read(:client_key) }
46+
assert_raise(ArgumentError) { cache.read(:client_key) { } }
47+
salt = iterations = nil
48+
assert_raise(SASL::Error) { cache.read(:client_key, salt:, iterations:) { } }
49+
50+
salt, iterations = "salt1", 5000
51+
assert_equal "ck1", cache.read(:client_key, salt:, iterations:) { "ck1" }
52+
assert_equal "ck1", cache.client_key
53+
assert_equal "salt1", cache.salt
54+
assert_equal 5000, cache.iterations
55+
assert_equal "ck1", cache.read(:client_key, salt:, iterations:) { "update" }
56+
assert_equal "ck1", cache.client_key
57+
58+
salt, iterations = "salt2", 9999
59+
assert_equal "ck2", cache.read(:client_key, salt:, iterations:) { "ck2" }
60+
assert_equal "ck2", cache.client_key
61+
assert_equal "salt2", cache.salt
62+
assert_equal 9999, cache.iterations
63+
64+
assert_equal "sk", cache.read(:server_key, salt:, iterations:) { "sk" }
65+
assert cache.sufficient?
66+
end
67+
68+
end

test/net/imap/test_authenticators.rb

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,94 @@ def test_scram_sha256_authenticator
211211
"v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4="
212212
)
213213
assert authenticator.done?
214+
# check cache values
215+
assert_kind_of Net::IMAP::SASL::ScramCache, authenticator.cache
216+
assert_equal "xKSVEDI6tPlSysH6mUQZOeeOp01r6B3fcJbodRPcYV0=".unpack1("m"),
217+
authenticator.cache.salted_password
218+
assert_equal "pg/JI9Z+hkSpLRa5btpe9GVrDHJcSEN0viVTVXaZbos=".unpack1("m"),
219+
authenticator.cache.client_key
220+
assert_equal "wfPLwcE6nTWhTAmQ7tl2KeoiWGPlZqQxSrmfPwDl2dU=".unpack1("m"),
221+
authenticator.cache.server_key
222+
end
223+
224+
def test_scram_sha256_with_cached_salted_password
225+
cache = Net::IMAP::SASL::ScramCache[
226+
salt: "W22ZaJ0SNY7soEsUEjb6gQ==".unpack1("m"),
227+
iterations: 4096,
228+
salted_password: "xKSVEDI6tPlSysH6mUQZOeeOp01r6B3fcJbodRPcYV0=".unpack1("m")
229+
]
230+
authenticator = scram_sha256("user", cache:, cnonce: "rOprNGfwEbeRWgbNEkqO")
231+
# n = no channel binding
232+
# a = authzid
233+
# n = authcid
234+
# r = random nonce (client)
235+
assert_equal("n,,n=user,r=rOprNGfwEbeRWgbNEkqO",
236+
authenticator.process(nil))
237+
refute authenticator.done?
238+
assert_equal(
239+
# c = b64 of gs2 header and channel binding data
240+
# r = random nonce (client + server)
241+
# p = b64 client proof
242+
# s = salt
243+
# i = iteration count
244+
"c=biws," \
245+
"r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0," \
246+
"p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=",
247+
authenticator.process(
248+
"r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0," \
249+
"s=W22ZaJ0SNY7soEsUEjb6gQ==," \
250+
"i=4096")
251+
)
252+
refute authenticator.done?
253+
assert_empty authenticator.process(
254+
"v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4="
255+
)
256+
assert authenticator.done?
257+
# check cache values
258+
assert_same cache, authenticator.cache
259+
assert_equal "pg/JI9Z+hkSpLRa5btpe9GVrDHJcSEN0viVTVXaZbos=".unpack1("m"),
260+
authenticator.cache.client_key
261+
assert_equal "wfPLwcE6nTWhTAmQ7tl2KeoiWGPlZqQxSrmfPwDl2dU=".unpack1("m"),
262+
authenticator.cache.server_key
263+
end
264+
265+
def test_scram_sha256_with_cached_client_and_server_keys
266+
cache = Net::IMAP::SASL::ScramCache[
267+
salt: "W22ZaJ0SNY7soEsUEjb6gQ==".unpack1("m"),
268+
iterations: 4096,
269+
client_key: "pg/JI9Z+hkSpLRa5btpe9GVrDHJcSEN0viVTVXaZbos=".unpack1("m"),
270+
server_key: "wfPLwcE6nTWhTAmQ7tl2KeoiWGPlZqQxSrmfPwDl2dU=".unpack1("m"),
271+
]
272+
authenticator = scram_sha256("user", cache:, cnonce: "rOprNGfwEbeRWgbNEkqO")
273+
# n = no channel binding
274+
# a = authzid
275+
# n = authcid
276+
# r = random nonce (client)
277+
assert_equal("n,,n=user,r=rOprNGfwEbeRWgbNEkqO",
278+
authenticator.process(nil))
279+
refute authenticator.done?
280+
assert_equal(
281+
# c = b64 of gs2 header and channel binding data
282+
# r = random nonce (client + server)
283+
# p = b64 client proof
284+
# s = salt
285+
# i = iteration count
286+
"c=biws," \
287+
"r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0," \
288+
"p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=",
289+
authenticator.process(
290+
"r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0," \
291+
"s=W22ZaJ0SNY7soEsUEjb6gQ==," \
292+
"i=4096")
293+
)
294+
refute authenticator.done?
295+
assert_empty authenticator.process(
296+
"v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4="
297+
)
298+
assert authenticator.done?
299+
# check cache values
300+
assert_same cache, authenticator.cache
301+
assert_nil authenticator.cache.salted_password
214302
end
215303

216304
# ----------------------

0 commit comments

Comments
 (0)