Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/net/imap/sasl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
68 changes: 51 additions & 17 deletions lib/net/imap/sasl/scram_authenticator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

require_relative "gs2_header"
require_relative "scram_algorithm"
require_relative "scram_cache"

module Net
class IMAP
Expand Down Expand Up @@ -50,9 +51,22 @@ module SASL
#
# === Caching SCRAM secrets
#
# <em>Caching of salted_password, client_key, stored_key, and server_key
# is not supported yet.</em>
# 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:** <em>The cache object must be handled with the same level of
# caution as the password itself.</em> 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 <em>not thread-safe</em>. Concurrent
# authentications should dup or clone the cache object.
class ScramAuthenticator
include GS2Header
include ScramAlgorithm
Expand All @@ -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).
Expand All @@ -82,17 +97,28 @@ class ScramAuthenticator
# *NOTE:* <em>It is the user's responsibility</em> 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,
password: nil, secret: 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

Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
109 changes: 109 additions & 0 deletions lib/net/imap/sasl/scram_cache.rb
Original file line number Diff line number Diff line change
@@ -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:** <em>The cache object must be handled with the same level of
# caution as the password itself.</em> 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 <em>not thread-safe</em>. 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 "#<struct %s %s>", 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("#<struct %s", PP.mcall(self, Kernel, :class).name), '>') {
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 "#<FILTERED %d bytes>", value.bytesize
in nil then "nil"
else format "#<FILTERED %s>", value.class
end
end

end

end
end
end
Loading