using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; using System.Security.Cryptography; using System.Text; namespace S22.Sasl.Mechanisms { /// /// Implements the Sasl Cram-Md5 authentication method as described in /// RFC 2831. /// internal class SaslDigestMd5 : SaslMechanism { bool Completed = false; /// /// The client nonce value used during authentication. /// string Cnonce = GenerateCnonce(); /// /// Cram-Md5 involves several steps. /// int Step = 0; /// /// The server sends the first message in the authentication exchange. /// public override bool HasInitial { get { return false; } } /// /// True if the authentication exchange between client and server /// has been completed. /// public override bool IsCompleted { get { return Completed; } } /// /// The IANA name for the Digest-Md5 authentication mechanism as described /// in RFC 2195. /// public override string Name { get { return "DIGEST-MD5"; } } /// /// The username to authenticate with. /// string Username { get { return Properties.ContainsKey("Username") ? Properties["Username"] as string : null; } set { Properties["Username"] = value; } } /// /// The password to authenticate with. /// string Password { get { return Properties.ContainsKey("Password") ? Properties["Password"] as string : null; } set { Properties["Password"] = value; } } /// /// Private constructor for use with Sasl.SaslFactory. /// private SaslDigestMd5() { // Nothing to do here. } /// /// Internal constructor used for unit testing. /// /// The username to authenticate with. /// The plaintext password to authenticate /// with. /// The client nonce value to use. /// Thrown if the username /// or the password parameter is null. /// Thrown if the username /// parameter is empty. internal SaslDigestMd5(string username, string password, string cnonce) : this(username, password) { Cnonce = cnonce; } /// /// Creates and initializes a new instance of the SaslDigestMd5 class /// using the specified username and password. /// /// The username to authenticate with. /// The plaintext password to authenticate /// with. /// Thrown if the username /// or the password parameter is null. /// Thrown if the username /// parameter is empty. public SaslDigestMd5(string username, string password) { username.ThrowIfNull("username"); if (username == String.Empty) throw new ArgumentException("The username must not be empty."); password.ThrowIfNull("password"); Username = username; Password = password; } /// /// Computes the client response to the specified Digest-Md5 challenge. /// /// The challenge sent by the server /// The response to the Digest-Md5 challenge. /// Thrown if the response could not /// be computed. protected override byte[] ComputeResponse(byte[] challenge) { if (Step == 1) Completed = true; // If authentication succeeded, the server responds with another // challenge (which we ignore) which the client must acknowledge // with a CRLF. byte[] ret = Step == 0 ? ComputeDigestResponse(challenge) : new byte[0]; Step = Step + 1; return ret; } private byte[] ComputeDigestResponse(byte[] challenge) { // Precondition: Ensure username and password are not null and // username is not empty. if (String.IsNullOrEmpty(Username) || Password == null) { throw new SaslException("The username must not be null or empty and " + "the password must not be null."); } // Parse the challenge-string and construct the "response-value" from it. string decoded = Encoding.ASCII.GetString(challenge); NameValueCollection fields = ParseDigestChallenge(decoded); string digestUri = "imap/" + fields["realm"]; string responseValue = ComputeDigestResponseValue(fields, Cnonce, digestUri, Username, Password); // Create the challenge-response string. string[] directives = new string[] { // We don't use UTF-8 in the current implementation. //"charset=utf-8", "username=" + Dquote(Username), "realm=" + Dquote(fields["realm"]), "nonce="+ Dquote(fields["nonce"]), "nc=00000001", "cnonce=" + Dquote(Cnonce), "digest-uri=" + Dquote(digestUri), "response=" + responseValue, "qop=" + fields["qop"] }; string challengeResponse = String.Join(",", directives); // Finally, return the response as a byte array. return Encoding.ASCII.GetBytes(challengeResponse); } /// /// Parses the challenge string sent by the server in response to a Digest-Md5 /// authentication request. /// /// The challenge sent by the server as part of /// "Step One" of the Digest-Md5 authentication mechanism. /// An initialized NameValueCollection instance made up of the /// attribute/value pairs contained in the challenge. /// Thrown if the challenge parameter /// is null. /// Refer to RFC 2831 section 2.1.1 for a detailed description of the /// format of the challenge sent by the server. private static NameValueCollection ParseDigestChallenge(string challenge) { challenge.ThrowIfNull("challenge"); NameValueCollection coll = new NameValueCollection(); string[] parts = challenge.Split(','); foreach (string p in parts) { string[] kv = p.Split(new char[] { '=' }, 2); if (kv.Length == 2) coll.Add(kv[0], kv[1].Trim('"')); } return coll; } /// /// Computes the "response-value" hex-string which is part of the /// Digest-MD5 challenge-response. /// /// A collection containing the attributes /// and values of the challenge sent by the server. /// The cnonce value to use for computing /// the response-value. /// The "digest-uri" string to use for /// computing the response-value. /// The username to use for computing the /// response-value. /// The password to use for computing the /// response-value. /// A string containing a hash-value which is part of the /// response sent by the client. /// Refer to RFC 2831, section 2.1.2.1 for a detailed /// description of the computation of the response-value. private static string ComputeDigestResponseValue(NameValueCollection challenge, string cnonce, string digestUri, string username, string password) { // The username, realm and password are encoded with ISO-8859-1 // (Compare RFC 2831, p. 10). Encoding enc = Encoding.GetEncoding("ISO-8859-1"); string ncValue = "00000001", realm = challenge["realm"]; // Construct A1. using (var md5p = new MD5CryptoServiceProvider()) { byte[] data = enc.GetBytes(username + ":" + realm + ":" + password); data = md5p.ComputeHash(data); string A1 = enc.GetString(data) + ":" + challenge["nonce"] + ":" + cnonce; // Construct A2. string A2 = "AUTHENTICATE:" + digestUri; if (!"auth".Equals(challenge["qop"])) A2 = A2 + ":00000000000000000000000000000000"; string ret = MD5(A1, enc) + ":" + challenge["nonce"] + ":" + ncValue + ":" + cnonce + ":" + challenge["qop"] + ":" + MD5(A2, enc); return MD5(ret, enc); } } /// /// Calculates the MD5 hash value for the specified string. /// /// The string to calculate the MD5 hash value for. /// The encoding to employ for encoding the /// characters in the specified string into a sequence of bytes for /// which the MD5 hash will be calculated. /// An MD5 hash as a 32-character hex-string. /// Thrown if the input string /// is null. private static string MD5(string s, Encoding encoding = null) { if (s == null) throw new ArgumentNullException("s"); if (encoding == null) encoding = Encoding.UTF8; byte[] data = encoding.GetBytes(s); byte[] hash = (new MD5CryptoServiceProvider()).ComputeHash(data); StringBuilder builder = new StringBuilder(); foreach (byte h in hash) builder.Append(h.ToString("x2")); return builder.ToString(); } /// /// Encloses the specified string in double-quotes. /// /// The string to enclose in double-quote characters. /// The enclosed string. private static string Dquote(string s) { return "\"" + s + "\""; } /// /// Generates a random cnonce-value which is a "client-specified data string /// which must be different each time a digest-response is sent". /// /// A random "cnonce-value" string. private static string GenerateCnonce() { return Guid.NewGuid().ToString("N").Substring(0, 16); } } }