Skip to content

Commit ed2e1b8

Browse files
nevanssingpolyma
andcommitted
🔒 Add SASL SCRAM-SHA-* mechanisms
Loosely based on the implementation by @singpolyma at nevans/net-sasl#5 Co-authored-by: Stephen Paul Weber <[email protected]>
1 parent a0ede93 commit ed2e1b8

File tree

9 files changed

+547
-0
lines changed

9 files changed

+547
-0
lines changed

lib/net/imap.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,6 +1007,17 @@ def starttls(options = {}, verify = true)
10071007
#
10081008
# Login using clear-text username and password.
10091009
#
1010+
# +SCRAM-SHA-1+::
1011+
# +SCRAM-SHA-256+::
1012+
# See ScramAuthenticator[rdoc-ref:Net::IMAP::SASL::ScramAuthenticator].
1013+
#
1014+
# Login by username and password. The password is not sent to the
1015+
# server but is used in a salted challenge/response exchange.
1016+
# +SCRAM-SHA-1+ and +SCRAM-SHA-256+ are directly supported by
1017+
# Net::IMAP::SASL. New authenticators can easily be added for any other
1018+
# <tt>SCRAM-*</tt> mechanism if the digest algorithm is supported by
1019+
# OpenSSL::Digest.
1020+
#
10101021
# +XOAUTH2+::
10111022
# See XOAuth2Authenticator[rdoc-ref:Net::IMAP::SASL::XOAuth2Authenticator].
10121023
#

lib/net/imap/sasl.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,17 @@ class IMAP
3232
#
3333
# Login using clear-text username and password.
3434
#
35+
# +SCRAM-SHA-1+::
36+
# +SCRAM-SHA-256+::
37+
# See ScramAuthenticator.
38+
#
39+
# Login by username and password. The password is not sent to the
40+
# server but is used in a salted challenge/response exchange.
41+
# +SCRAM-SHA-1+ and +SCRAM-SHA-256+ are directly supported by
42+
# Net::IMAP::SASL. New authenticators can easily be added for any other
43+
# <tt>SCRAM-*</tt> mechanism if the digest algorithm is supported by
44+
# OpenSSL::Digest.
45+
#
3546
# +XOAUTH2+::
3647
# See XOAuth2Authenticator.
3748
#
@@ -69,8 +80,13 @@ module SASL
6980

7081
sasl_dir = File.expand_path("sasl", __dir__)
7182
autoload :Authenticators, "#{sasl_dir}/authenticators"
83+
autoload :GS2Header, "#{sasl_dir}/gs2_header"
84+
autoload :ScramAlgorithm, "#{sasl_dir}/scram_algorithm"
85+
autoload :ScramAuthenticator, "#{sasl_dir}/scram_authenticator"
7286

7387
autoload :PlainAuthenticator, "#{sasl_dir}/plain_authenticator"
88+
autoload :ScramSHA1Authenticator, "#{sasl_dir}/scram_sha1_authenticator"
89+
autoload :ScramSHA256Authenticator, "#{sasl_dir}/scram_sha256_authenticator"
7490
autoload :XOAuth2Authenticator, "#{sasl_dir}/xoauth2_authenticator"
7591

7692
autoload :CramMD5Authenticator, "#{sasl_dir}/cram_md5_authenticator"

lib/net/imap/sasl/authenticators.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ def initialize(use_defaults: false)
3434
@authenticators = {}
3535
if use_defaults
3636
add_authenticator "Plain"
37+
add_authenticator "Scram-SHA-1"
38+
add_authenticator "Scram-SHA-256"
3739
add_authenticator "XOAuth2"
3840
add_authenticator "Login" # deprecated
3941
add_authenticator "Cram-MD5" # deprecated

lib/net/imap/sasl/gs2_header.rb

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# frozen_string_literal: true
2+
3+
module Net
4+
class IMAP < Protocol
5+
module SASL
6+
7+
# Originally defined for the GS2 mechanism family in
8+
# RFC5801[https://tools.ietf.org/html/rfc5801],
9+
# several different mechanisms start with a GS2 header:
10+
# * +GS2-*+ --- RFC5801[https://tools.ietf.org/html/rfc5801]
11+
# * +SCRAM-*+ --- RFC5802[https://tools.ietf.org/html/rfc5802],
12+
# see ScramAuthenticator.
13+
# * +SAML20+ --- RFC6595[https://tools.ietf.org/html/rfc6595]
14+
# * +OPENID20+ --- RFC6616[https://tools.ietf.org/html/rfc6616]
15+
# * +OAUTH10A+ --- RFC7628[https://tools.ietf.org/html/rfc7628]
16+
# * +OAUTHBEARER+ --- RFC7628[https://tools.ietf.org/html/rfc7628]
17+
#
18+
# Classes that include this module must implement +#authzid+.
19+
module GS2Header
20+
NO_NULL_CHARS = /\A[^\x00]+\z/u.freeze # :nodoc:
21+
22+
##
23+
# Matches {RFC5801 §4}[https://www.rfc-editor.org/rfc/rfc5801#section-4]
24+
# +saslname+. The output from gs2_saslname_encode matches this Regexp.
25+
RFC5801_SASLNAME = /\A(?:[^,=\x00]|=2C|=3D)+\z/u.freeze
26+
27+
# The {RFC5801 §4}[https://www.rfc-editor.org/rfc/rfc5801#section-4]
28+
# +gs2-header+, which prefixes the #initial_client_response.
29+
#
30+
# >>>
31+
# <em>Note: the actual GS2 header includes an optional flag to
32+
# indicate that the GSS mechanism is not "standard", but since all of
33+
# the SASL mechanisms using GS2 are "standard", we don't include that
34+
# flag. A class for a nonstandard GSSAPI mechanism should prefix with
35+
# "+F,+".</em>
36+
def gs2_header
37+
"#{gs2_cb_flag},#{gs2_authzid},"
38+
end
39+
40+
# The {RFC5801 §4}[https://www.rfc-editor.org/rfc/rfc5801#section-4]
41+
# +gs2-cb-flag+:
42+
#
43+
# "+n+":: The client doesn't support channel binding.
44+
# "+y+":: The client does support channel binding
45+
# but thinks the server does not.
46+
# "+p+":: The client requires channel binding.
47+
# The selected channel binding follows "+p=+".
48+
#
49+
# The default always returns "+n+". A mechanism that supports channel
50+
# binding must override this method.
51+
#
52+
def gs2_cb_flag; "n" end
53+
54+
# The {RFC5801 §4}[https://www.rfc-editor.org/rfc/rfc5801#section-4]
55+
# +gs2-authzid+ header, when +#authzid+ is not empty.
56+
#
57+
# If +#authzid+ is empty or +nil+, an empty string is returned.
58+
def gs2_authzid
59+
return "" if authzid.nil? || authzid == ""
60+
"a=#{gs2_saslname_encode(authzid)}"
61+
end
62+
63+
module_function
64+
65+
# Encodes +str+ to match RFC5801_SASLNAME.
66+
def gs2_saslname_encode(str)
67+
str = str.encode("UTF-8")
68+
# Regexp#match raises "invalid byte sequence" for invalid UTF-8
69+
NO_NULL_CHARS.match str or
70+
raise ArgumentError, "invalid saslname: %p" % [str]
71+
str
72+
.gsub(?=, "=3D")
73+
.gsub(?,, "=2C")
74+
end
75+
76+
end
77+
end
78+
end
79+
end
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# frozen_string_literal: true
2+
3+
module Net
4+
class IMAP
5+
module SASL
6+
7+
# For method descriptions,
8+
# see {RFC5802 §2.2}[https://www.rfc-editor.org/rfc/rfc5802#section-2.2]
9+
# and {RFC5802 §3}[https://www.rfc-editor.org/rfc/rfc5802#section-3].
10+
module ScramAlgorithm
11+
def Normalize(str) SASL.saslprep(str) end
12+
13+
def Hi(str, salt, iterations)
14+
length = digest.digest_length
15+
OpenSSL::KDF.pbkdf2_hmac(
16+
str,
17+
salt: salt,
18+
iterations: iterations,
19+
length: length,
20+
hash: digest,
21+
)
22+
end
23+
24+
def H(str) digest.digest str end
25+
26+
def HMAC(key, data) OpenSSL::HMAC.digest(digest, key, data) end
27+
28+
def XOR(str1, str2)
29+
str1.unpack("C*")
30+
.zip(str2.unpack("C*"))
31+
.map {|a, b| a ^ b }
32+
.pack("C*")
33+
end
34+
35+
def auth_message
36+
[
37+
client_first_message_bare,
38+
server_first_message,
39+
client_final_message_without_proof,
40+
]
41+
.join(",")
42+
end
43+
44+
def salted_password
45+
Hi(Normalize(password), salt, iterations)
46+
end
47+
48+
def client_key; HMAC(salted_password, "Client Key") end
49+
def server_key; HMAC(salted_password, "Server Key") end
50+
def stored_key; H(client_key) end
51+
def client_signature; HMAC(stored_key, auth_message) end
52+
def server_signature; HMAC(server_key, auth_message) end
53+
def client_proof; XOR(client_key, client_signature) end
54+
end
55+
56+
end
57+
end
58+
end

0 commit comments

Comments
 (0)