-
Notifications
You must be signed in to change notification settings - Fork 29
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Loosely based on the implementation by @singpolyma at nevans/net-sasl#5 Co-authored-by: Stephen Paul Weber <[email protected]>
- Loading branch information
1 parent
43a73ec
commit 317c619
Showing
9 changed files
with
469 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.