Skip to content

Commit

Permalink
🔒 Add SASL SCRAM-SHA-* mechanisms
Browse files Browse the repository at this point in the history
Based on the implementation by @singpolyma at
nevans/net-sasl#5

Co-authored-by: Stephen Paul Weber <singpolyma@singpolyma.net>
  • Loading branch information
nevans and singpolyma committed Sep 10, 2023
1 parent ad5cbfc commit 2b27764
Show file tree
Hide file tree
Showing 9 changed files with 636 additions and 0 deletions.
11 changes: 11 additions & 0 deletions lib/net/imap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1007,6 +1007,17 @@ def starttls(options = {}, verify = true)
#
# Login using clear-text username and password.
#
# +SCRAM-SHA-1+::
# +SCRAM-SHA-256+::
# See ScramAuthenticator[Net::IMAP::SASL::ScramAuthenticator].
#
# Login by username and password. The password is not sent to the
# server but is used in a salted challenge/response exchange.
# +SCRAM-SHA-1+ and +SCRAM-SHA-256+ are directly supported by
# Net::IMAP::SASL. New authenticators can easily be added for any other
# <tt>SCRAM-*</tt> mechanism if the digest algorithm is supported by
# OpenSSL::Digest.
#
# +XOAUTH2+::
# See XOAuth2Authenticator[Net::IMAP::SASL::XOAuth2Authenticator].
#
Expand Down
16 changes: 16 additions & 0 deletions lib/net/imap/sasl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ class IMAP
#
# Login using clear-text username and password.
#
# +SCRAM-SHA-1+::
# +SCRAM-SHA-256+::
# See ScramAuthenticator[Net::IMAP::SASL::ScramAuthenticator].
#
# Login by username and password. The password is not sent to the
# server but is used in a salted challenge/response exchange.
# +SCRAM-SHA-1+ and +SCRAM-SHA-256+ are directly supported by
# Net::IMAP::SASL. New authenticators can easily be added for any other
# <tt>SCRAM-*</tt> mechanism if the digest algorithm is supported by
# OpenSSL::Digest.
#
# +XOAUTH2+::
# See XOAuth2Authenticator[Net::IMAP::SASL::XOAuth2Authenticator].
#
Expand Down Expand Up @@ -69,8 +80,13 @@ module SASL

sasl_dir = File.expand_path("sasl", __dir__)
autoload :Authenticators, "#{sasl_dir}/authenticators"
autoload :GS2Header, "#{sasl_dir}/gs2_header"
autoload :ScramAlgorithm, "#{sasl_dir}/scram_algorithm"
autoload :ScramAuthenticator, "#{sasl_dir}/scram_authenticator"

autoload :PlainAuthenticator, "#{sasl_dir}/plain_authenticator"
autoload :ScramSHA1Authenticator, "#{sasl_dir}/scram_sha1_authenticator"
autoload :ScramSHA256Authenticator, "#{sasl_dir}/scram_sha256_authenticator"
autoload :XOAuth2Authenticator, "#{sasl_dir}/xoauth2_authenticator"

autoload :CramMD5Authenticator, "#{sasl_dir}/cram_md5_authenticator"
Expand Down
2 changes: 2 additions & 0 deletions lib/net/imap/sasl/authenticators.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ def initialize(use_defaults: false)
@authenticators = {}
if use_defaults
add_authenticator "Plain"
add_authenticator "Scram-SHA-1"
add_authenticator "Scram-SHA-256"
add_authenticator "XOAuth2"
add_authenticator "Login" # deprecated
add_authenticator "Cram-MD5" # deprecated
Expand Down
79 changes: 79 additions & 0 deletions lib/net/imap/sasl/gs2_header.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# frozen_string_literal: true

module Net
class IMAP < Protocol
module SASL

# Several mechanisms start with a GS2 header:
# * +GS2-*+
# * +SCRAM-*+ --- ScramAuthenticator
# * +OPENID20+
# * +SAML20+
# * +OAUTH10A+
# * +OAUTHBEARER+ --- OAuthBearerAuthenticator
#
# Classes that include this must implement +#authzid+.
module GS2Header
NO_NULL_CHARS = /\A[^\x00]+\z/u.freeze # :nodoc:

##
# Matches {RFC5801 §4}[https://www.rfc-editor.org/rfc/rfc5801#section-4]
# +saslname+. The output from gs2_saslname_encode matches this Regexp.
RFC5801_SASLNAME = /\A(?:[^,=\x00]|=2C|=3D)+\z/u.freeze

# The {RFC5801 §4}[https://www.rfc-editor.org/rfc/rfc5801#section-4]
# +gs2-header+, which prefixes the #initial_client_response.
#
# >>>
# <em>Note: the actual GS2 header includes an optional flag to
# indicate that the GSS mechanism is not "standard", but since all of
# the SASL mechanisms using GS2 are "standard", we don't include that
# flag. A class for a nonstandard GSSAPI mechanism should prefix with
# "+F,+".</em>
def gs2_header
"#{gs2_cb_flag},#{gs2_authzid},"
end

# The {RFC5801 §4}[https://www.rfc-editor.org/rfc/rfc5801#section-4]
# +gs2-cb-flag+:
#
# "+n+":: The client doesn't support channel binding.
# "+y+":: The client does support channel binding
# but thinks the server does not.
# "+p+":: The client requires channel binding.
# The selected channel binding follows "+p=+".
#
# The default always returns "+n+". A mechanism that supports channel
# binding must override this method.
#
def gs2_cb_flag; "n" end

# The {RFC5801 §4}[https://www.rfc-editor.org/rfc/rfc5801#section-4]
# +gs2-authzid+ header, when +#authzid+ is not empty.
#
# If +#authzid+ is empty or +nil+, an empty string is returned.
def gs2_authzid
return "" if authzid.nil? || authzid == ""
"a=#{gs2_saslname_encode(authzid)}"
end

module_function

# Encodes +str+ to match RFC5801_SASLNAME.
#
#--
# TODO: validate NO_NULL_CHARS and valid UTF-8 in the attr_writer.
def gs2_saslname_encode(str)
str = str.encode("UTF-8")
# Regexp#match raises "invalid byte sequence" for invalid UTF-8
NO_NULL_CHARS.match str or
raise ArgumentError, "invalid saslname: %p" % [str]
str
.gsub(?=, "=3D")
.gsub(?,, "=2C")
end

end
end
end
end
69 changes: 69 additions & 0 deletions lib/net/imap/sasl/scram_algorithm.rb
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 2b27764

Please sign in to comment.