Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[🚧 WIP] SASL refactoring and new mechanisms #78

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion lib/net/imap/sasl/authenticators.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def names; @authenticators.keys end
# When only a single argument is given, the authenticator class will be
# lazily loaded from <tt>Net::IMAP::SASL::#{name}Authenticator</tt> (case is
# preserved and non-alphanumeric characters are removed..
def add_authenticator(name, authenticator = nil)
def add_authenticator(name, authenticator = nil, warn_overwrite: true)
authenticator ||= begin
class_name = "#{name.gsub(/[^a-zA-Z0-9]/, "")}Authenticator".to_sym
auth_class = nil
Expand All @@ -78,6 +78,11 @@ def add_authenticator(name, authenticator = nil)
}
end
key = Authenticators.normalize_name(name)
if warn_overwrite && (original = @authenticators[key])
warn("%p: replacing existing %p authenticator: %p" % [
self, key, original
], uplevel: 1)
end
@authenticators[key] = authenticator
end

Expand Down
112 changes: 68 additions & 44 deletions lib/net/imap/sasl/digest_md5_authenticator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,6 @@ def initialize(user = nil, pass = nil, authz = nil,
authcid: nil, secret: nil,
realm: nil, service: "imap", host: nil, service_name: nil,
warn_deprecation: true, **)
username = authcid || username || user or
raise ArgumentError, "missing username (authcid)"
password ||= secret || pass or raise ArgumentError, "missing password"
authzid ||= authz
if warn_deprecation
warn("WARNING: DIGEST-MD5 SASL mechanism was deprecated by RFC6331.",
category: :deprecated)
Expand All @@ -168,11 +164,25 @@ def initialize(user = nil, pass = nil, authz = nil,
require "digest/md5"
require "securerandom"
require "strscan"
@username, @password, @authzid = username, password, authzid

[authcid, username, user].compact.count == 1 or
raise ArgumentError, "conflicting values for username"
[authzid, authz].compact.count <= 1 or
raise ArgumentError, "conflicting values for authzid"
[password, secret, pass].compact.count == 1 or
raise ArgumentError, "conflicting values for password"

@username = authcid || username || user
@password = password || secret || pass
@authzid = authzid || authz
@realm = realm
@host = host
@service = service
@service_name = service_name

@username or raise ArgumentError, "missing username (authcid)"
@password or raise ArgumentError, "missing password"

@nc, @stage = {}, STAGE_ONE
end

Expand All @@ -198,42 +208,11 @@ def process(challenge)
case @stage
when STAGE_ONE
@stage = STAGE_TWO
@sparams = parse_challenge(challenge)
@qop = sparams.key?("qop") ? ["auth"] : sparams["qop"].flatten
@nonce = sparams["nonce"] &.first
@charset = sparams["charset"]&.first
@realm ||= sparams["realm"] &.last
@host ||= realm

if !qop.include?("auth")
raise DataFormatError, "Server does not support auth (qop = %p)" % [
sparams["qop"]
]
elsif (emptykey = REQUIRED.find { sparams[_1].empty? })
raise DataFormatError, "Server didn't send %s (%p)" % [emptykey, challenge]
elsif (multikey = NO_MULTIPLES.find { sparams[_1].length > 1 })
raise DataFormatError, "Server sent multiple %s (%p)" % [multikey, challenge]
end

response = {
nonce: nonce,
username: username,
realm: realm,
cnonce: SecureRandom.base64(32),
"digest-uri": digest_uri,
qop: "auth",
maxbuf: 65535,
nc: "%08d" % nc(nonce),
charset: charset,
}

response[:authzid] = @authzid unless @authzid.nil?

response[:response] = response_value(response)
format_response(response)
process_stage_one(challenge)
stage_one_response
when STAGE_TWO
@stage = STAGE_DONE
raise ResponseParseError, challenge unless challenge =~ /rspauth=/
process_stage_two(challenge)
"" # if at the second stage, return an empty string
else
raise ResponseParseError, challenge
Expand Down Expand Up @@ -284,14 +263,59 @@ def split_quoted_list(value, challenge)
end

def nc(nonce)
if @nc.has_key? nonce
@nc[nonce] = @nc[nonce] + 1
else
@nc[nonce] = 1
@nc[nonce] = @nc.key?(nonce) ? @nc[nonce] + 1 : 1
@nc[nonce]
end

def process_stage_one(challenge)
@sparams = parse_challenge(challenge)
@qop = sparams.key?("qop") ? ["auth"] : sparams["qop"].flatten

guard_stage_one(challenge)

@nonce = sparams["nonce"] .first
@charset = sparams["charset"].first

@realm ||= sparams["realm"].last
@host ||= realm
end

def guard_stage_one(challenge)
if !qop.include?("auth")
raise DataFormatError, "Server does not support auth (qop = %p)" % [
sparams["qop"]
]
elsif (emptykey = REQUIRED.find { sparams[_1].empty? })
raise DataFormatError, "Server didn't send %s (%p)" % [emptykey, challenge]
elsif (multikey = NO_MULTIPLES.find { sparams[_1].length > 1 })
raise DataFormatError, "Server sent multiple %s (%p)" % [multikey, challenge]
end
end

def response_value(response)
def stage_one_response
response = {
nonce: nonce,
username: username,
realm: realm,
cnonce: SecureRandom.base64(32),
"digest-uri": digest_uri,
qop: "auth",
maxbuf: 65535,
nc: "%08d" % nc(nonce),
charset: charset,
}

response[:authzid] = authzid unless authzid.nil?
response[:response] = compute_digest(response)

format_response(response)
end

def process_stage_two(challenge)
raise ResponseParseError, challenge unless challenge =~ /rspauth=/
end

def compute_digest(response)
a1 = compute_a1(response)
a2 = compute_a2(response)
Digest::MD5.hexdigest(
Expand Down