Skip to content

Commit bdd490c

Browse files
committed
✨ SASL SCRAM-SHA-*: Add mechanisms [🚧 tests, split commit]
Also, don't forget to credit the PR on net-sasl for getting this started!
1 parent 7271b58 commit bdd490c

File tree

8 files changed

+672
-55
lines changed

8 files changed

+672
-55
lines changed

lib/net/imap.rb

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -805,32 +805,55 @@ def starttls(options = {}, verify = true)
805805
#
806806
# ==== Supported SASL Mechanisms
807807
#
808+
#--
809+
# n.b. the following table is copy/pasted from SASL::Authenticator.
810+
#++
811+
#
808812
# Net::IMAP currently supports the following mechanisms:
809813
#
810-
# PLAIN:: Login using clear-text user and password. Secure with TLS.
811-
# See SASL::PlainAuthenticator.
812-
# ANONYMOUS:: Allow the user to gain access to public services or resources
813-
# without authenticating or disclosing identity to the server.
814-
# See SASL::AnonymousAuthenticator.
815-
# XOAUTH2:: Login using a username and OAuth2 access token. Non-standard
816-
# and obsoleted by +OAUTHBEARER+, but still widely supported.
817-
# See SASL::XOAuth2Authenticator.
814+
# +PLAIN+:: See SASL::PlainAuthenticator.
815+
# Login using clear-text username and password.
816+
# +SCRAM-*+:: See SASL::ScramAuthenticator.
817+
# Login by username and password. The password is not sent
818+
# to the server but is used in a salted challenge/response
819+
# exchange. One of the benefits over +PLAIN+ is that the
820+
# server cannot impersonate the user to other servers.
821+
# +SCRAM-SHA-1+ and +SCRAM-SHA-256+ are supported, but any
822+
# algorithm supported by OpenSSL::Digest can easily be
823+
# added.
824+
# +OAUTHBEARER+:: See SASL::OAuthBearerAuthenticator.
825+
# Login using an OAUTH2 Bearer token. This is the
826+
# standard mechanism for using OAuth2 with \SASL, but it
827+
# is not yet deployed as widely as +XOAUTH2+.
828+
# +XOAUTH2+:: See SASL::XOAuth2Authenticator.
829+
# Login using a username and OAuth2 access token.
830+
# Non-standard and obsoleted by +OAUTHBEARER+, but widely
831+
# supported.
832+
# +EXTERNAL+:: See SASL::ExternalAuthenticator.
833+
# Login using already established credentials, such as a TLS
834+
# certificate or IPsec.
835+
# +ANONYMOUS+:: See SASL::AnonymousAuthenticator.
836+
# Allow the user to gain access to public services or
837+
# resources without authenticating or disclosing an
838+
# identity.
839+
#
840+
# >>>
841+
# *Deprecated:* <em>Obsolete mechanisms are available for backwards
842+
# compatibility.</em>
843+
#
844+
# For +DIGEST-MD5+ see SASL::DigestMD5Authenticator.
845+
#
846+
# For +LOGIN+, see SASL::LoginAuthenticator.
847+
#
848+
# For +CRAM-MD5+, see SASL::CramMD5Authenticator.
849+
#
850+
# <em>Using a deprecated mechanism will print a warning.</em>
818851
#
819852
# See Net::IMAP::Authenticators for information on plugging in
820853
# authenticators for other mechanisms. See the {SASL mechanism
821854
# registry}[https://www.iana.org/assignments/sasl-mechanisms/sasl-mechanisms.xhtml]
822855
# for information on these and other SASL mechanisms.
823856
#
824-
# ===== Deprecated mechanisms
825-
#
826-
# <em>Obsolete mechanisms are available for backwards compatibility.
827-
# Using a deprecated mechanism will print a warning.</em>
828-
#
829-
# DIGEST-MD5:: DEPRECATED by RFC6331. Must be secured using TLS.
830-
# See SASL::DigestMD5Authenticator.
831-
# CRAM-MD5:: DEPRECATED: Use +PLAIN+ (or SCRAM-*)
832-
# LOGIN:: DEPRECATED: Use +PLAIN+ with TLS.
833-
#
834857
# ==== Capabilities
835858
#
836859
# Clients MUST NOT attempt to #authenticate or #login when +LOGINDISABLED+

lib/net/imap/authenticators.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ def authenticators
102102
require_relative "sasl/anonymous_authenticator"
103103
require_relative "sasl/external_authenticator"
104104
require_relative "sasl/oauthbearer_authenticator"
105+
require_relative "sasl/scram_authenticator"
105106

106107
# deprecated
107108
require_relative "sasl/login_authenticator"

lib/net/imap/sasl.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ module SASL
2828
autoload :StringPrep, File.expand_path("sasl/stringprep", __dir__)
2929
autoload :SASLprep, File.expand_path("#{__dir__}/sasl/saslprep", __dir__)
3030

31+
# Error raised when the client SASL::Authenticator determines that it
32+
# cannot complete successfully during a call to Authenticator#process.
33+
#
34+
# Note that most \SASL mechanisms cannot detect or report errors until the
35+
# protocol-specific outcome message, e.g. a tagged response in \IMAP.
36+
# Those authentication errors will be handled or raised by the protocol
37+
# client, e.g. a Net::IMAP::NoResponseError.
38+
class AuthenticationFailure < Error
39+
end
40+
3141
# ArgumentError raised when +string+ is invalid for the stringprep
3242
# +profile+.
3343
class StringPrepError < ArgumentError

lib/net/imap/sasl/authenticator.rb

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,47 @@ module SASL
1515
# please consult the documentation for the specific mechanisms you are
1616
# using:
1717
#
18-
# * +PLAIN+ --- PlainAuthenticator
19-
# * +XOAUTH2+ --- XOAuth2Authenticator
20-
# * +EXTERNAL+ --- ExternalAuthenticator
21-
# * +ANONYMOUS+ --- AnonymousAuthenticator
22-
# * +OAUTHBEARER+ --- OAuthBearerAuthenticator
23-
# * +SCRAM-SHA-*+ --- TODO
18+
#--
19+
# n.b. the following table is copy/pasted to Net::IMAP#authenticate.
20+
#++
21+
#
22+
# +PLAIN+:: See SASL::PlainAuthenticator.
23+
# Login using clear-text username and password.
24+
# +SCRAM-*+:: See SASL::ScramAuthenticator.
25+
# Login by username and password. The password is not sent
26+
# to the server but is used in a salted challenge/response
27+
# exchange. One of the benefits over +PLAIN+ is that the
28+
# server cannot impersonate the user to other servers.
29+
# +SCRAM-SHA-1+ and +SCRAM-SHA-256+ are supported, but any
30+
# algorithm supported by OpenSSL::Digest can easily be
31+
# added.
32+
# +OAUTHBEARER+:: See SASL::OAuthBearerAuthenticator.
33+
# Login using an OAUTH2 Bearer token. This is the
34+
# standard mechanism for using OAuth2 with \SASL, but it
35+
# is not yet deployed as widely as +XOAUTH2+.
36+
# +XOAUTH2+:: See SASL::XOAuth2Authenticator.
37+
# Login using a username and OAuth2 access token.
38+
# Non-standard and obsoleted by +OAUTHBEARER+, but widely
39+
# supported.
40+
# +EXTERNAL+:: See SASL::ExternalAuthenticator.
41+
# Login using already established credentials, such as a TLS
42+
# certificate or IPsec.
43+
# +ANONYMOUS+:: See SASL::AnonymousAuthenticator.
44+
# Allow the user to gain access to public services or
45+
# resources without authenticating or disclosing an
46+
# identity.
47+
#
48+
# >>>
49+
# *Deprecated:* <em>Obsolete mechanisms are available for backwards
50+
# compatibility.</em>
51+
#
52+
# For +DIGEST-MD5+ see SASL::DigestMD5Authenticator.
53+
#
54+
# For +LOGIN+, see SASL::LoginAuthenticator.
55+
#
56+
# For +CRAM-MD5+, see SASL::CramMD5Authenticator.
2457
#
25-
# [Deprecated:]
26-
# DIGEST-MD5[rdoc-ref:DigestMD5Authenticator],
27-
# LOGIN[rdoc-ref:LoginAuthenticator], and
28-
# CRAM-MD5[rdoc-ref:CramMD5Authenticator]
58+
# <em>Using a deprecated mechanism will print a warning.</em>
2959
#
3060
# \Authenticators should be created and used internally by a protocol
3161
# client's authentication command, e.g. Net::IMAP#authenticate for \IMAP.
@@ -76,7 +106,6 @@ module SASL
76106
# * +scram_sha1_salted_passwords+, +scram_sha256_salted_password+ ---
77107
# Salted password(s) (with salt and iteration count) for the +SCRAM-*+
78108
# mechanism family. <tt>[salt, iterations, pbkdf2_hmac]</tt> tuple.
79-
# <em>(not implemented yet...)</em>
80109
# * +passcode+ --- passcode for SecurID 2FA <em>(not implemented)</em>
81110
# * +pin+ --- Personal Identification number, e.g. for SecurID 2FA
82111
# <em>(not implemented)</em>
@@ -277,7 +306,7 @@ def initialize(*, **, &callback)
277306
# See PlainAuthenticator or DigestMD5Authenticator for example
278307
# authenticator implementations.
279308
def process(server_challenge_string)
280-
raise NotImplementedError, "#{__method__} is defined by subclasses"
309+
raise NoMethodError, "#{__method__} is defined by subclasses"
281310
end
282311

283312
# :call-seq:

lib/net/imap/sasl/gs2_header.rb

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# frozen_string_literal: true
2+
3+
module Net
4+
class IMAP < Protocol
5+
module SASL
6+
7+
# Several mechanisms start with a GS2 header:
8+
# * +GS2-*+
9+
# * +SCRAM-*+ --- ScramAuthenticator
10+
# * +OPENID20+
11+
# * +SAML20+
12+
# * +OAUTH10A+
13+
# * +OAUTHBEARER+ --- OAuthBearerAuthenticator
14+
#
15+
# Classes that include this must implement +#authzid+.
16+
module GS2Header
17+
NO_NULL_CHARS = /\A[^\x00]+\z/u # :nodoc:
18+
19+
##
20+
# Matches {RFC5801 §4}[https://www.rfc-editor.org/rfc/rfc5801#section-4]
21+
# +saslname+. The output from gs2_saslname_encode matches this Regexp.
22+
RFC5801_SASLNAME = /\A(?:[^,=\x00]|=2C|=3D)+\z/u
23+
24+
# The {RFC5801 §4}[https://www.rfc-editor.org/rfc/rfc5801#section-4]
25+
# +gs2-header+, which prefixes the #initial_client_response.
26+
#
27+
# >>>
28+
# <em>Note: the actual GS2 header includes an optional flag to
29+
# indicate that the GSS mechanism is not "standard", but since all of
30+
# the SASL mechanisms using GS2 are "standard", we don't include that
31+
# flag. A class for a nonstandard GSSAPI mechanism should prefix with
32+
# "+F,+".</em>
33+
def gs2_header
34+
"#{gs2_cb_flag},#{gs2_authzid},"
35+
end
36+
37+
# The {RFC5801 §4}[https://www.rfc-editor.org/rfc/rfc5801#section-4]
38+
# +gs2-cb-flag+:
39+
#
40+
# "+n+":: The client doesn't support channel binding.
41+
# "+y+":: The client does support channel binding
42+
# but thinks the server does not.
43+
# "+p+":: The client requires channel binding.
44+
# The selected channel binding follows "+p=+".
45+
#
46+
# The default always returns "+n+". A mechanism that supports channel
47+
# binding must override this method.
48+
#
49+
def gs2_cb_flag; "n" end
50+
51+
# The {RFC5801 §4}[https://www.rfc-editor.org/rfc/rfc5801#section-4]
52+
# +gs2-authzid+ header, when +#authzid+ is not empty.
53+
#
54+
# If +#authzid+ is empty or +nil+, an empty string is returned.
55+
def gs2_authzid
56+
return "" if authzid.nil? || authzid == ""
57+
"a=#{gs2_saslname_encode(authzid)}"
58+
end
59+
60+
module_function
61+
62+
# Encodes +str+ to match RFC5801_SASLNAME.
63+
#
64+
#--
65+
# TODO: validate NO_NULL_CHARS and valid UTF-8 in the attr_writer.
66+
def gs2_saslname_encode(str)
67+
str = str.encode("UTF-8")
68+
if NO_NULL_CHARS.match str
69+
str
70+
.gsub(?=, "=3D")
71+
.gsub(?,, "=2C")
72+
else
73+
# Regexp#match raises "invalid byte sequence" for invalid UTF-8
74+
raise ArgumentError, "invalid saslname: %p" % [str]
75+
end
76+
end
77+
78+
end
79+
end
80+
end
81+
end

lib/net/imap/sasl/oauthbearer_authenticator.rb

Lines changed: 6 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require_relative "authenticator"
4+
require_relative "gs2_header"
45

56
module Net
67
class IMAP < Protocol
@@ -11,15 +12,11 @@ module SASL
1112
# * OAUTHBEARER[rdoc-ref:OAuthBearerAuthenticator]
1213
# * OAUTH10A
1314
class OAuthAuthenticator < Authenticator
15+
include GS2Header
16+
1417
# <b>Implemented by subclasses.</b>
1518
def self.mechanism_name; raise NotImplementedError end
1619

17-
##
18-
# #authzid must match this Regexp. From
19-
# RFC5801[https://www.rfc-editor.org/rfc/rfc5801]:
20-
# saslname = 1*(UTF8-char-safe / "=2C" / "=3D")
21-
RFC5801_SASLNAME = /\A(?:[^,=\x00]+|=2C|=3D)\z/u
22-
2320
# Creates an OAuthBearerAuthenticator or OAuth10aAuthenticator.
2421
#
2522
# * +_subclass_var_+ — the subclass's required parameter.
@@ -104,32 +101,16 @@ def process(data)
104101
##
105102
# Returns true when the initial client response was sent.
106103
#
107-
# The authentication should not succeed until this is true, but this
104+
# The authentication should not succeed unless this returns true, but it
108105
# does *not* indicate success.
109106
def done?; @done end
110107

111-
# The {RFC7628 §3.1}[https://www.rfc-editor.org/rfc/rfc7628#section-3.1] formatted response.
108+
# The {RFC7628 §3.1}[https://www.rfc-editor.org/rfc/rfc7628#section-3.1]
109+
# formatted response.
112110
def initial_client_response
113111
[gs2_header, *kv_pairs.map {|kv| kv.join("=") }, "\1"].join("\1")
114112
end
115113

116-
# The {RFC5801 §4}[https://www.rfc-editor.org/rfc/rfc5801#section-4]
117-
# +gs2-header+, which prefixes the #initial_client_response.
118-
#
119-
# The +OAUTHBEARER+ and +OAUTH10A+ mechanisms don't use
120-
# +gs2-nonstd-flag+ and don't support channel binding. So the
121-
# +gs2-header+ is always either "<tt>n,a=#{authzid},</tt>" or
122-
# "<tt>n,,</tt>"
123-
def gs2_header
124-
if authzid.nil? then "n,,"
125-
elsif RFC5801_SASLNAME.match? authzid then "n,a=#{authzid},"
126-
else
127-
# TODO: validate in the attr_writer
128-
# Regexp#match? raises "invalid byte sequence" for invalid UTF-8
129-
raise ArgumentError, "invalid chars in authzid %p" % [authzid]
130-
end
131-
end
132-
133114
# The key value pairs which follow gs2_header, as a Hash.
134115
def kv_pairs
135116
{

0 commit comments

Comments
 (0)