# 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
#
# The SCRAM-*-PLUS mechanisms and channel binding are not
# supported yet.
#
# === Caching SCRAM secrets
#
# Caching of salted_password, client_key, stored_key, and server_key
# is not supported yet.
#
class ScramAuthenticator
include GS2Header
include ScramAlgorithm
# :call-seq:
# new(username, password, **options) -> auth_ctx
# new(username:, password:, **options) -> auth_ctx
# new(authcid:, 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
#
# * #authcid ― Identity whose #password is used.
#
# #username - An alias for #authcid.
# * #password ― Password or passphrase associated with this #username.
# * _optional_ #authzid ― Alternate identity to act as or on behalf of.
# * _optional_ #min_iterations - Overrides the default value (4096).
#
# Any other keyword parameters are quietly ignored.
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
cnonce: nil, # must only be set in tests
**options)
@username = username || username_arg || authcid or
raise ArgumentError, "missing username (authcid)"
@password = password || secret || password_arg or
raise ArgumentError, "missing 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.
#
# RFC-2831[https://tools.ietf.org/html/rfc2831] uses the term +username+.
# "Authentication identity" is the generic term used by
# RFC-4422[https://tools.ietf.org/html/rfc4422].
# RFC-4616[https://tools.ietf.org/html/rfc4616] and many later RFCs abbreviate
# this to +authcid+.
attr_reader :username
alias authcid username
# A password or passphrase that matches the #username.
attr_reader :password
alias secret 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.
#
# The class's +DIGEST_NAME+ constant must be set to the name of an
# algorithm supported by OpenSSL::Digest.
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
# Authenticator for the "+SCRAM-SHA-1+" SASL mechanism, defined in
# RFC5802[https://tools.ietf.org/html/rfc5802].
#
# Uses the "SHA-1" digest algorithm from OpenSSL::Digest.
#
# See ScramAuthenticator.
class ScramSHA1Authenticator < ScramAuthenticator
DIGEST_NAME = "SHA1"
end
# Authenticator for the "+SCRAM-SHA-256+" SASL mechanism, defined in
# RFC7677[https://tools.ietf.org/html/rfc7677].
#
# Uses the "SHA-256" digest algorithm from OpenSSL::Digest.
#
# See ScramAuthenticator.
class ScramSHA256Authenticator < ScramAuthenticator
DIGEST_NAME = "SHA256"
end
end
end
end