Skip to content

Commit

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

Co-authored-by: Stephen Paul Weber <[email protected]>
  • Loading branch information
nevans and singpolyma committed Sep 15, 2023
1 parent 43a73ec commit 317c619
Show file tree
Hide file tree
Showing 9 changed files with 469 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 @@ -1014,6 +1014,17 @@ def starttls(options = {}, verify = true)
#
# Login using clear-text username and password.
#
# +SCRAM-SHA-1+::
# +SCRAM-SHA-256+::
# See ScramAuthenticator[rdoc-ref: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[rdoc-ref: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 @@ -39,6 +39,17 @@ class IMAP
#
# Login using clear-text username and password.
#
# +SCRAM-SHA-1+::
# +SCRAM-SHA-256+::
# See 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.
#
Expand Down Expand Up @@ -77,8 +88,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 :OAuthBearerAuthenticator, "#{sasl_dir}/oauthbearer_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 @@ -35,6 +35,8 @@ def initialize(use_defaults: false)
if use_defaults
add_authenticator "OAuthBearer"
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
1 change: 1 addition & 0 deletions lib/net/imap/sasl/gs2_header.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ module SASL
# several different mechanisms start with a GS2 header:
# * +GS2-*+ --- RFC5801[https://tools.ietf.org/html/rfc5801]
# * +SCRAM-*+ --- RFC5802[https://tools.ietf.org/html/rfc5802]
# (ScramAuthenticator)
# * +SAML20+ --- RFC6595[https://tools.ietf.org/html/rfc6595]
# * +OPENID20+ --- RFC6616[https://tools.ietf.org/html/rfc6616]
# * +OAUTH10A+ --- RFC7628[https://tools.ietf.org/html/rfc7628]
Expand Down
58 changes: 58 additions & 0 deletions lib/net/imap/sasl/scram_algorithm.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# frozen_string_literal: true

module Net
class IMAP
module SASL

# For method descriptions,
# see {RFC5802 §2.2}[https://www.rfc-editor.org/rfc/rfc5802#section-2.2]
# and {RFC5802 §3}[https://www.rfc-editor.org/rfc/rfc5802#section-3].
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_without_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
257 changes: 257 additions & 0 deletions lib/net/imap/sasl/scram_authenticator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
# frozen_string_literal: true

require "openssl"
require "securerandom"

require_relative "gs2_header"
require_relative "scram_algorithm"

module Net
class IMAP
module SASL

# Abstract base class for the "+SCRAM-*+" family of SASL mechanisms,
# defined in RFC5802[https://tools.ietf.org/html/rfc5802]. Use via
# Net::IMAP#authenticate.
#
# Directly supported:
# * +SCRAM-SHA-1+ --- ScramSHA1Authenticator
# * +SCRAM-SHA-256+ --- ScramSHA256Authenticator
#
# New +SCRAM-*+ mechanisms can easily be added for any hash algorithm
# supported by
# OpenSSL::Digest[https://ruby.github.io/openssl/OpenSSL/Digest.html].
# Subclasses need only set an appropriate +DIGEST_NAME+ constant.
#
# === SCRAM algorithm
#
# See the documentation and method definitions on ScramAlgorithm for an
# overview of the algorithm. The different mechanisms differ only by
# which hash function that is used (or by support for channel binding with
# +-PLUS+).
#
# See also the methods on GS2Header.
#
# ==== Server messages
#
# As server messages are received, they are validated and loaded into
# the various attributes, e.g: #snonce, #salt, #iterations, #verifier,
# #server_error, etc.
#
# Unlike many other SASL mechanisms, the +SCRAM-*+ family supports mutual
# authentication and can return server error data in the server messages.
# If #process raises an Error for the server-final-message, then
# server_error may contain error details.
#
# === TLS Channel binding
#
# <em>The <tt>SCRAM-*-PLUS</tt> mechanisms and channel binding are not
# supported yet.</em>
#
# === Caching SCRAM secrets
#
# <em>Caching of salted_password, client_key, stored_key, and server_key
# is not supported yet.</em>
#
class ScramAuthenticator
include GS2Header
include ScramAlgorithm

# :call-seq:
# new(username, password, **options) -> auth_ctx
# new(username:, password:, **options) -> auth_ctx
#
# Creates an authenticator for one of the "+SCRAM-*+" SASL mechanisms.
# Each subclass defines #digest to match a specific mechanism.
#
# Called by Net::IMAP#authenticate and similar methods on other clients.
#
# === Parameters
#
# * #username ― Identity whose #password is used. Aliased as #authcid.
# * #password ― Password or passphrase associated with this #username.
# * #authzid ― Alternate identity to act as or on behalf of. Optional.
# * #min_iterations - Overrides the default value (4096). Optional.
#
# See the documentation on the corresponding attributes for more.
def initialize(username_arg = nil, password_arg = nil,
authcid: nil, username: nil, password: nil, authzid: nil,
min_iterations: 4096, # see both RFC5802 and RFC7677
cnonce: nil, # must only be set in tests
**options)
@username = username || username_arg || authcid or
raise ArgumentError, "missing username (authcid)"
[authcid, username, username_arg].compact.count == 1 or
raise ArgumentError, "conflicting values for authcid (username)"
@password = password || password_arg or
raise ArgumentError, "missing password"
[password, password_arg].compact.count == 1 or
raise ArgumentError, "conflicting values for password"
@authzid = authzid

@min_iterations = Integer min_iterations
@min_iterations.positive? or
raise ArgumentError, "min_iterations must be positive"
@cnonce = cnonce || SecureRandom.base64(32)
end

# Authentication identity: the identity that matches the #password.
attr_reader :username
alias authcid username

# A password or passphrase that matches the #username.
attr_reader :password

# Authorization identity: an identity to act as or on behalf of. The
# identity form is application protocol specific. If not provided or
# left blank, the server derives an authorization identity from the
# authentication identity. For example, an administrator or superuser
# might take on another role:
#
# imap.authenticate "SCRAM-SHA-256", "root", passwd, authzid: "user"
#
# The server is responsible for verifying the client's credentials and
# verifying that the identity it associates with the client's
# authentication identity is allowed to act as (or on behalf of) the
# authorization identity.
attr_reader :authzid

# The minimal allowed iteration count. Lower #iterations will raise an
# Error.
attr_reader :min_iterations

# The client nonce, generated by SecureRandom
attr_reader :cnonce

# The server nonce, which must start with #cnonce
attr_reader :snonce

# The salt used by the server for this user
attr_reader :salt

# The iteration count for the selected hash function and user
attr_reader :iterations

# 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

# Returns a new OpenSSL::Digest object, set to the appropriate hash
# function for the chosen mechanism.
#
# <em>The class's +DIGEST_NAME+ constant must be set to the name of an
# algorithm supported by OpenSSL::Digest.</em>
def digest; OpenSSL::Digest.new self.class::DIGEST_NAME end

# See {RFC5802 §7}[https://www.rfc-editor.org/rfc/rfc5802#section-7]
# +client-first-message+.
def initial_client_response
"#{gs2_header}#{client_first_message_bare}"
end

# responds to the server's challenges
def process(challenge)
case (@state ||= :initial_client_response)
when :initial_client_response
initial_client_response.tap { @state = :server_first_message }
when :server_first_message
recv_server_first_message challenge
final_message_with_proof.tap { @state = :server_final_message }
when :server_final_message
recv_server_final_message challenge
"".tap { @state = :done }
else
raise Error, "server sent after complete, %p" % [challenge]
end
rescue Exception => ex
@state = ex
raise
end

# Is the authentication exchange complete?
#
# If false, another server continuation is required.
def done?; @state == :done end

private

# Need to store this for auth_message
attr_reader :server_first_message

def format_message(hash) hash.map { _1.join("=") }.join(",") end

def recv_server_first_message(server_first_message)
@server_first_message = server_first_message
sparams = parse_challenge server_first_message
@snonce = sparams["r"] or
raise Error, "server did not send nonce"
@salt = sparams["s"]&.unpack1("m") or
raise Error, "server did not send salt"
@iterations = sparams["i"]&.then {|i| Integer i } or
raise Error, "server did not send iteration count"
min_iterations <= iterations or
raise Error, "too few iterations: %d" % [iterations]
mext = sparams["m"] and
raise Error, "mandatory extension: %p" % [mext]
snonce.start_with? cnonce or
raise Error, "invalid server nonce"
end

def recv_server_final_message(server_final_message)
sparams = parse_challenge server_final_message
@server_error = sparams["e"] and
raise Error, "server error: %s" % [server_error]
verifier = sparams["v"].unpack1("m") or
raise Error, "server did not send verifier"
verifier == server_signature or
raise Error, "server verify failed: %p != %p" % [
server_signature, verifier
]
end

# See {RFC5802 §7}[https://www.rfc-editor.org/rfc/rfc5802#section-7]
# +client-first-message-bare+.
def client_first_message_bare
@client_first_message_bare ||=
format_message(n: gs2_saslname_encode(SASL.saslprep(username)),
r: cnonce)
end

# See {RFC5802 §7}[https://www.rfc-editor.org/rfc/rfc5802#section-7]
# +client-final-message+.
def final_message_with_proof
proof = [client_proof].pack("m0")
"#{client_final_message_without_proof},p=#{proof}"
end

# See {RFC5802 §7}[https://www.rfc-editor.org/rfc/rfc5802#section-7]
# +client-final-message-without-proof+.
def client_final_message_without_proof
@client_final_message_without_proof ||=
format_message(c: [cbind_input].pack("m0"), # channel-binding
r: snonce) # nonce
end

# See {RFC5802 §7}[https://www.rfc-editor.org/rfc/rfc5802#section-7]
# +cbind-input+.
#
# >>>
# *TODO:* implement channel binding, appending +cbind-data+ here.
alias cbind_input gs2_header

# RFC5802 specifies "that the order of attributes in client or server
# messages is fixed, with the exception of extension attributes", but
# this parses it simply as a hash, without respect to order. Note that
# repeated keys (violating the spec) will use the last value.
def parse_challenge(challenge)
challenge.split(/,/).to_h {|pair| pair.split(/=/, 2) }
rescue ArgumentError
raise Error, "unparsable challenge: %p" % [challenge]
end

end
end
end
end
Loading

0 comments on commit 317c619

Please sign in to comment.