9
\$\begingroup\$

I've created a security helper class that attempts to adhere to the National Institute of Standards and Technology (NIST) "Digital Identity Guidelines" SP800-63B [June 2017] specs. These guidelines propose new rules for password creation and storage. In a nutshell, my implementation addresses the following rules:

Password Guidelines & Usability Considerations

  • Memorized secrets SHALL be at least 8 characters in length if chosen by the subscriber.
  • Allow at least 64 characters in length to support the use of passphrases. Encourage users to make memorized secrets as lengthy as they want, using any characters they like (including spaces), thus aiding memorization.
  • Do not impose other composition rules (e.g. mixtures of different character types) on memorized secrets.
  • When processing requests to establish and change memorized secrets, verifiers SHALL compare the prospective secrets against a list that contains values known to be commonly-used, expected, or compromised. For example, the list MAY include, but is not limited to:
    • Passwords obtained from previous breach corpuses.
    • Dictionary words.
    • Repetitive or sequential characters (e.g. aaaaaa, 1234abcd).
    • Context-specific words, such as the name of the service, the username, and derivatives thereof.
  • Provide clear, meaningful and actionable feedback when chosen passwords are rejected (e.g., when it appears on a “black list” of unacceptable passwords or has been used previously).
  • If Unicode characters are accepted in memorized secrets, the verifier SHOULD apply the Normalization Process for Stabilized Strings using either the NFKC or NFKD normalization defined in Section 12.1 of Unicode Standard Annex 15 [UAX 15].

Password Storage Guidelines

  • Memorized secrets SHALL be salted and hashed using a suitable one-way key derivation function. Key derivation functions take a password, a salt, and a cost factor as inputs then generate a password hash.
  • This salt value SHALL be generated by an approved random bit generator [SP 800-90Ar1] and provide at least the minimum security strength specified in the latest revision of SP 800-131A (112 bits as of the date of this publication).
  • Both the salt value and the resulting hash SHALL be stored for each subscriber using a memorized secret authenticator.

My Implementation Using Method HashSecurePassword()

  • Passwords are forced to be between 8 and 64 characters, inclusive.
  • Passwords that contain 3 or more sequentially-repeated characters (e.g. password111) are disallowed.
  • Passwords that contain repeated phrases of length 3 or more (e.g. 123pass123) are disallowed.
  • Passwords that contain personally identifiable information (PII) (e.g. firstname, lastname, emailaddress, etc.) are disallowed.
  • Passwords that are found in a common word dictionary or in the SecLists 10 million blacklisted-passwords database (e.g. Oklahoma, $xkaw93fubpq) are disallowed.

  • Passwords that do not make it beyond the conditions above will return a null value along with a custom out PasswordStatus enum value giving detailed information about the password's failed state.
  • Passwords that surpass the conditions above are considered "secure" and will be hashed using Argon2 (default), sCrypt, or bCrypt with appropriate cryptoRNG salt and iteration rounds.
  • Successfully-hashed passwords all have a string encoding ready for storage in a database management system. The encoded string is a concatenation consisting of the hash technique name, number of iterations and/or memory cost, the base64 salt, and the base64 hash (e.g. $argon2i$v=19$m=131072,t=6,p=1$SCvNXMwOaGpX2ZOC+OfjKQ$/hdjThjxp9VY2sFG2KWZDSlh9ZgZXLpKCe8B9BVwaeA).

The Helper Class:

using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using CryptSharp;
using CryptSharp.Utility;
using Konscious.Security.Cryptography;

// Install-Package CryptSharpOfficial
// Install-Package Konscious.Security.Cryptography.Argon2

namespace OUTKST 
{
    /// <summary>
    /// Contains methods necessary for calculating, validating, and verifying passwords
    /// used in secure applications. These methods were created in accordance with the
    /// <a href="https://pages.nist.gov/800-63-3/sp800-63b.html">National Institute of Standards 
    /// and Technology (NIST) "Digital Identity Guidelines" SP800-63B</a> [June 4, 2018].
    /// </summary>
    public static class Security
    {
        /// <summary>
        /// Enumeration of the hashing techniques available.
        /// </summary>
        public enum HashTechnique
        {
            /// <summary>Uses the best available hashing technique.</summary>
            BestAvailable = 0,
            /// <summary>The Argon2 memory-hard hashing technique. [<a href="https://en.wikipedia.org/wiki/Argon2">Argon2 Wiki</a>]</summary>
            Argon2 = BestAvailable,
            /// <summary>The BCrypt Blowfish Cipher hashing technique. [<a href="https://en.wikipedia.org/wiki/Bcrypt">BCrypt Wiki</a>]</summary>
            BCrypt,
            /// <summary>The SCrypt Key Derivation Function hashing technique. [<a href="https://en.wikipedia.org/wiki/Scrypt">SCrypt Wiki</a>]</summary>
            SCrypt
        }

        /// <summary>
        /// Enumeration of the compliance status of a checked password against <a href="https://pages.nist.gov/800-63-3/sp800-63b.html">NIST SP800-63B</a> guidelines.
        /// </summary>
        public enum PasswordStatus
        {
            /// <summary>Unknown status due to no action or unhandled exception.</summary>
            Unknown = 0,
            /// <summary>The password should be considered "secure" according to NIST SP800-63B guidelines.</summary>
            Secure,
            /// <summary>The password should be considered OK to use, with the caveat that personal information from the password's owner was not analyzed.</summary>
            OKPendingPersonalInfo,
            /// <summary>The password is insecure; the password was found in a common word dictionary and/or the SecList 10mil blacklisted-passwords database.</summary>
            BadBlacklisted,
            /// <summary>The password is insecure; personal information from the password's owner was found within the password string.</summary>
            BadContainsPersonalInfo,
            /// <summary>The password is insecure; the composition of the password is not well-formed. Password contains more than (<see cref="Utilities.PASSWORD_REPEAT_CHARS_ALLOWED"/>) consecutively-repeated characters, or repeated phrases of length greater than (<see cref="Utilities.PASSWORD_REPEAT_PHRASELENGTH_ALLOWED"/>) characters.</summary>
            BadInvalidComposition,
            /// <summary>The password is insecure; the length of the password is not between (<see cref="Utilities.PASSWORD_MIN_LENGTH"/>) and (<see cref="Utilities.PASSWORD_MAX_LENGTH"/>) characters, inclusive.</summary>
            BadInvalidLength
        }

        /// <summary>
        /// Verifies the supplied password string against the hashed version. Analyzes the supplied hash
        /// string format to determine its hashing technique.
        /// </summary>
        /// <param name="plaintextPassword">The given password to check. This string value is normalized before operations are performed.</param>
        /// <param name="encodedPassword">The hashed string of the given password to check against.</param>
        /// <returns><c>True</c> if the plaintext password is verified against the hashed password; otherwise <c>False</c>.</returns>
        /// <remarks>Returns <c>NotSupportedException</c> if the hashing technique could not be determined.</remarks>
        public static bool CheckPassword(string plaintextPassword, string encodedPassword)
        {
            if (!plaintextPassword.IsNormalized()) { plaintextPassword = plaintextPassword.Normalize(); }

            if (encodedPassword.StartsWith("$argon2")) {
                return Utilities.CheckArgon2Hash(plaintextPassword, encodedPassword);
            }

            if (encodedPassword.StartsWith("$2y")) {
                return Utilities.CheckBCryptHash(plaintextPassword, encodedPassword);
            }

            if (encodedPassword.StartsWith("$s2")) {
                return Utilities.CheckSCryptHash(plaintextPassword, encodedPassword);
            }

            throw new NotSupportedException(@"The given hashed password technique could not be determined.");
        }

        /// <summary>
        /// Checks the password according to <a href="https://pages.nist.gov/800-63-3/sp800-63b.html">NIST SP800-63B</a> recommendations. 
        /// If determined to be secure, this method then hashes the given password using the best available technique. The string returned
        /// is encoded for immediate storage in a database management system.
        /// </summary>
        /// <param name="password">The given password to hash. This string value is normalized before operations are performed.</param>
        /// <param name="status">The status describing why the password is considered secure or insecure.</param>
        /// <param name="hashTechnique">(Optional) The hashing technique to use for hashing the given password. 
        ///     <c>HashTechnique.BestAvailable</c> will use the best available technique. An invalid <c>HashTechnique</c> will
        ///     use <c>HashTechnique.BestAvailable</c>.</param>
        /// <param name="personalInfo">(Optional) A list of string parameters that contain personal information about the 
        ///     password's owner (e.g. FirstName, LastName, EmailAddress, etc). This personal information will be 
        ///     checked against the password to ensure that the password does not contain these values.</param>
        /// <returns>If successful, returns a hashed version of the input string encoded for storage in a database management system;
        ///     otherwise returns <c>null</c>.</returns>
        /// <example>This example shows how to call the <c>HashSecurePassword</c> method using the best available hashing technique and 
        ///     passing in a list of personal information about the password's owner.
        ///     <code>
        ///         var encodedPassword = HashSecurePassword(password, out status, HashTechnique.BestAvailable, firstName, lastName, emailAddress, address1, city, state, postalCode);
        ///         if (encodedPassword == null) { throw new ArgumentException(string.Format("Invalid password provided. Reason: {0}", GetPasswordStatus(status))); }
        ///         
        ///         // continue
        ///     </code>
        /// </example>
        /// <remarks>In the case of a <c>null</c> return, check the <c>PasswordStatus</c> object to infer the exact reason the password
        ///     was not successfully created. Furthermore, if personal information fields were not supplied then the method will not return 
        ///     <c>PasswordStatus.Secure</c>; it will instead return <c>PasswordStatus.OKPendingPersonalInfo</c> to reflect 
        ///     that all NIST guidelines could not be tested.
        ///     <para>Use the <c>Security.Utilities</c> class to forego the checking of the password's compliance with <a href="https://pages.nist.gov/800-63-3/sp800-63b.html">NIST SP800-63B</a> 
        ///     for a hashed password.</para>
        /// </remarks>
        public static string HashSecurePassword(string password, out PasswordStatus status, HashTechnique hashTechnique = HashTechnique.BestAvailable, params string[] personalInfo)
        {
            if (!password.IsNormalized()) { password = password.Normalize(); }

            if (Utilities.IsPasswordSecure(password, out status, personalInfo)) {
                switch (hashTechnique) {
                    case HashTechnique.Argon2:
                        return Utilities.CalculateArgon2Hash(password);
                    case HashTechnique.BCrypt:
                        return Utilities.CalculateBCryptHash(password);
                    case HashTechnique.SCrypt:
                        return Utilities.CalculateSCryptHash(password);
                    default:
                        return HashSecurePassword(password, out status, HashTechnique.BestAvailable, personalInfo);
                }
            }

            return null;
        }

        /// <summary>
        /// A collection of utilities necessary to perform password compliance, hashing, and hash-checking.
        /// </summary>
        public static class Utilities {
            /// <summary>References a common word dictionary and the SecList 10 million entry blacklisted-passwords database.</summary>
            private static readonly HashSet<string> PASSWORD_BLACKLIST = new HashSet<string>(SecurityResources.PasswordBlacklist.Split(new[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase);
            /// <summary>The minimum allowable length of a password.</summary>
            private const int PASSWORD_MIN_LENGTH = 8;
            /// <summary>The maximum allowable length of a password.</summary>
            private const int PASSWORD_MAX_LENGTH = 64;
            /// <summary>The maximum allowable length of any sequentially-repeated character set within a password.</summary>
            private const int PASSWORD_REPEAT_CHARS_ALLOWED = 2;
            /// <summary>The maximum allowable length of any repetitive phrases within a password.</summary>
            private const int PASSWORD_REPEAT_PHRASELENGTH_ALLOWED = 2;
            /// <summary>The minimum character length to match personal identifiable information (PII) against a password.</summary>
            private const int PASSWORD_MIN_PII_LENGTH = 3;
            /// <summary>Matches any valid email address.</summary>
            private static readonly Regex REGEX_EMAIL = new Regex(@"^([a-zA-Z0-9]+([\.+_-][a-zA-Z0-9]+)*)@(([a-zA-Z0-9]+((\.|[-]{1,2})[a-zA-Z0-9]+)*)\.[a-zA-Z]{2,6})$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
            /// <summary>Matches any repetitive-character passwords exceeding more than <see cref="PASSWORD_REPEAT_CHARS_ALLOWED"/> repeated characters (e.g. <c>password111</c>).</summary>
            private static readonly Regex REGEX_LETTER_REPETITION = new Regex(@"(.)\1{x,}".Replace("x", PASSWORD_REPEAT_CHARS_ALLOWED.ToString()), RegexOptions.Compiled);
            /// <summary>Matches any repetitive-phrase passwords exceeding more than <see cref="PASSWORD_REPEAT_PHRASELENGTH_ALLOWED"/> or more characters per phrase (e.g. <c>123pass123</c>).</summary>
            private static readonly Regex REGEX_WORD_REPETITION = new Regex(@".*(.{x,}).*\1.*".Replace("x", (PASSWORD_REPEAT_PHRASELENGTH_ALLOWED + 1).ToString()), RegexOptions.Compiled);
            /// <summary>Matches, with groups, all valid Argon2 encoded strings in the format of e.g. <c>$argon2i$v=19$m=65536,t=2,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG</c></summary>
            private static readonly Regex ARGON2_REGEX = new Regex(@"^\$(?<variant>argon2[^$]*)\$v=(?<version>\d+)\$m=(?<memory>\d+),t=(?<iterations>\d+),p=(?<parallelism>\d+)\$(?<salt>[^$]+)\$(?<hash>[^$]+)$", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture);
            /// <summary>Matches, with groups, all valid sCrypt encoded strings in the format of e.g. <c>$s2$65536$8$4$UGWwnXhmZG1KDKLn4VY2Pw==$Pg2RVPyYmOeWzFKTr27qHn3FXGqgEifjFgv+jN5zTdM=</c></summary>
            private static readonly Regex SCRYPT_REGEX = new Regex(@"^\$(?<variant>s2[^$]*)\$(?<cost>\d+)\$(?<blocksize>\d+)\$(?<parallelism>\d+)\$(?<salt>[^$]+)\$(?<hash>[^$]+)$", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture);
            /// <summary>Holds default parallelism value for hashing algorithms; set to the number of processors of the machine. This is also known as the number of threads, <b>h</b>.</summary>
            private static readonly int DEGREE_OF_PARALLELISM = Environment.ProcessorCount;
            /// <summary>Holds default iterations value for the Argon2 hashing algorithm. This is also known as the time cost, <b>t</b>.</summary>
            private const int ARGON2_ITERATIONS = 3;
            /// <summary>Holds default iterations value for the BCrypt hashing algorithm. This is also known as the rounds, or <b>cost</b>.</summary>
            private const int BCRYPT_ITERATIONS = 13;
            /// <summary>Holds default iterations value for the SCrypt hashing algorithm. This is also known as the block size or BlockMix, <b>r</b>.</summary>
            private const int SCRYPT_ITERATIONS = 8;
            /// <summary>Holds default bCrypt hashing options; using the corrected '2y' variant with <see cref="BCRYPT_ITERATIONS"/> rounds of iteration.</summary>
            private static readonly CrypterOptions BCRYPT_OPTIONS = new CrypterOptions { { CrypterOption.Variant, BlowfishCrypterVariant.Corrected }, { CrypterOption.Rounds, BCRYPT_ITERATIONS } };
            /// <summary>Holds default memory block size value for hashing algorithms. This is also known as the memory cost, <b>m</b>.</summary>
            private const int MEMORY_SIZE = 65536;
            /// <summary>Holds default key length size value, in bytes, for encoded passwords.</summary>
            private const int KEY_LENGTH = 32;
            /// <summary>Holds default salt length size value, in bytes, for encoded passwords.</summary>
            private const int SALT_LENGTH = 16;
            /// <summary>Holds the current Argon2 implementation version. NOTE: This should be provided as a part of the Argon2 implementation and is thus considered temporary.</summary>
            private const string ARGON2_VERSION = "19";

            /// <summary>
            /// Calculates the Argon2id hash for a given string, using predefined parameters tailored
            /// specifically for password hashing and storage.
            /// Output is then encoded in the following format for storage:
            /// <code>$argon2id$v=19$m=<see cref="MEMORY_SIZE"/>,t=<see cref="ARGON2_ITERATIONS"/>,p=<see cref="DEGREE_OF_PARALLELISM"/>$<see cref="SALT_LENGTH"/>salt$<see cref="KEY_LENGTH"/>passwordhash</code>
            /// <para>Argon2 is specifically designed for user passwords that DO NOT meet the following requirements:</para>
            /// <list type="bullet">
            ///     <item><description>ASCII printable uniformly random passwords of length 20 or more (6.6 bits/character)</description></item>
            ///     <item><description>PINs of 40-digit length or more, chosen uniformly at random (3.3 bits/digit)</description></item>
            ///     <item><description>11-word or longer Diceware passphrases (12.9 bits/word)</description></item>
            ///     <item><description>12-word or longer XKCD "correct battery horse staple ..." passwords (2,048 word dictionary, words chosen uniformly at random)</description></item>
            /// </list>
            /// </summary>
            /// <param name="plaintextPassword">The given password to hash using the Argon2id hashing technique.</param>
            /// <returns>An Argon2id hashed encoding of the given input string.</returns>
            public static string CalculateArgon2Hash(string plaintextPassword)
            {
                return CalculateArgon2idHash(plaintextPassword, GenerateSalt());
            }

            /// <summary>
            /// Calculates the Argon2d hash for a given string, using the given parameters for password hashing and storage.
            /// Argon2d uses data-depending memory access, which makes it suitable for cryptocurrencies and proof-of-work
            /// applications with no threats from side-channel timing attacks.  The best tradeoff attack on t-pass Argon2d
            /// is the ranking tradeoff attack, which reduces the time-area product by the factor of 1.33.
            /// Output is then encoded in the following format for storage:
            /// <code>$argon2d$v=19$m=<see cref="MEMORY_SIZE"/>,t=<see cref="ARGON2_ITERATIONS"/>,p=<see cref="DEGREE_OF_PARALLELISM"/>$<see cref="SALT_LENGTH"/>salt$<see cref="KEY_LENGTH"/>passwordhash</code>
            /// <para>Argon2 is specifically designed for user passwords that DO NOT meet the following requirements:</para>
            /// <list type="bullet">
            ///     <item><description>ASCII printable uniformly random passwords of length 20 or more (6.6 bits/character)</description></item>
            ///     <item><description>PINs of 40-digit length or more, chosen uniformly at random (3.3 bits/digit)</description></item>
            ///     <item><description>11-word or longer Diceware passphrases (12.9 bits/word)</description></item>
            ///     <item><description>12-word or longer XKCD "correct battery horse staple ..." passwords (2,048 word dictionary, words chosen uniformly at random)</description></item>
            /// </list>
            /// </summary>
            /// <param name="plaintextPassword">The given password to hash using the Argon2d hashing technique.</param>
            /// <param name="salt">The salt to mix with the hash; default salt value length is <see cref="SALT_LENGTH"/> bytes.</param>
            /// <param name="parallelism">(Optional) The amount of threads, <b>h</b>, to use; default value is <see cref="DEGREE_OF_PARALLELISM"/> threads.</param>
            /// <param name="iterations">(Optional) The number of iterations, <b>t</b>, to use; default value is <see cref="ARGON2_ITERATIONS"/> iterations.</param>
            /// <param name="memorySize">(Optional) The size of memory, <b>m</b>, to use in KB; default value is <see cref="MEMORY_SIZE"/> KB.</param>
            /// <returns>An Argon2d hashed encoding of the given input string.</returns>
            public static string CalculateArgon2dHash(string plaintextPassword, byte[] salt, int? parallelism = null, int iterations = ARGON2_ITERATIONS, int memorySize = MEMORY_SIZE)
            {
                parallelism = parallelism ?? DEGREE_OF_PARALLELISM;
                salt = salt ?? GenerateSalt();

                string encodedPassword;

                using (Argon2d argon2 = new Argon2d(Encoding.UTF8.GetBytes(plaintextPassword)))
                {
                    argon2.DegreeOfParallelism = (int)parallelism;
                    argon2.Iterations = iterations;
                    argon2.MemorySize = memorySize;
                    argon2.Salt = salt;

                    encodedPassword = string.Format("${0}$v={1}$m={2},t={3},p={4}${5}${6}",
                        argon2.GetType().Name.ToLower(),
                        ARGON2_VERSION,
                        argon2.MemorySize,
                        argon2.Iterations,
                        argon2.DegreeOfParallelism,
                        Convert.ToBase64String(argon2.Salt),
                        Convert.ToBase64String(argon2.GetBytes(KEY_LENGTH))
                    );
                }

                return encodedPassword;
            }

            /// <summary>
            /// Calculates the Argon2i hash for a given string, using the given parameters for password hashing and storage.
            /// Argon2i uses data-independent memory access, which is preferred for password hashing and password-based key
            /// derivation. Argon2i is invulnerable to side-channel timing attacks, but is weaker against Time-memory tradeoff
            /// (TMTO) attacks.
            /// Output is then encoded in the following format for storage:
            /// <code>$argon2i$v=19$m=<see cref="MEMORY_SIZE"/>,t=<see cref="ARGON2_ITERATIONS"/>,p=<see cref="DEGREE_OF_PARALLELISM"/>$<see cref="SALT_LENGTH"/>salt$<see cref="KEY_LENGTH"/>passwordhash</code>
            /// <para>Argon2 is specifically designed for user passwords that DO NOT meet the following requirements:</para>
            /// <list type="bullet">
            ///     <item><description>ASCII printable uniformly random passwords of length 20 or more (6.6 bits/character)</description></item>
            ///     <item><description>PINs of 40-digit length or more, chosen uniformly at random (3.3 bits/digit)</description></item>
            ///     <item><description>11-word or longer Diceware passphrases (12.9 bits/word)</description></item>
            ///     <item><description>12-word or longer XKCD "correct battery horse staple ..." passwords (2,048 word dictionary, words chosen uniformly at random)</description></item>
            /// </list>
            /// </summary>
            /// <param name="plaintextPassword">The given password to hash using the Argon2i hashing technique.</param>
            /// <param name="salt">The salt to mix with the hash; default salt value length is <see cref="SALT_LENGTH"/> bytes.</param>
            /// <param name="parallelism">(Optional) The amount of threads, <b>h</b>, to use; default value is <see cref="DEGREE_OF_PARALLELISM"/> threads.</param>
            /// <param name="iterations">(Optional) The number of iterations, <b>t</b>, to use; default value is <see cref="ARGON2_ITERATIONS"/> iterations.</param>
            /// <param name="memorySize">(Optional) The size of memory, <b>m</b>, to use in KB; default value is <see cref="MEMORY_SIZE"/> KB.</param>
            /// <returns>An Argon2i hashed encoding of the given input string.</returns>
            public static string CalculateArgon2iHash(string plaintextPassword, byte[] salt, int? parallelism = null, int iterations = ARGON2_ITERATIONS, int memorySize = MEMORY_SIZE)
            {
                parallelism = parallelism ?? DEGREE_OF_PARALLELISM;
                salt = salt ?? GenerateSalt();

                string encodedPassword;

                using (Argon2i argon2 = new Argon2i(Encoding.UTF8.GetBytes(plaintextPassword)))
                {
                    argon2.DegreeOfParallelism = (int)parallelism;
                    argon2.Iterations = iterations;
                    argon2.MemorySize = memorySize;
                    argon2.Salt = salt;

                    encodedPassword = string.Format("${0}$v={1}$m={2},t={3},p={4}${5}${6}",
                        argon2.GetType().Name.ToLower(),
                        ARGON2_VERSION,
                        argon2.MemorySize,
                        argon2.Iterations,
                        argon2.DegreeOfParallelism,
                        Convert.ToBase64String(argon2.Salt),
                        Convert.ToBase64String(argon2.GetBytes(KEY_LENGTH))
                    );
                }

                return encodedPassword;
            }

            /// <summary>
            /// Calculates the Argon2id hash for a given string, using the given parameters for password hashing and storage.
            /// The best tradeoff attack on 1-pass Argon2id is the combined low-storage attack (for the first half of the
            /// memory) and the ranking attack (for the second half), which bring together the factor of about 2.1.
            /// Output is then encoded in the following format for storage:
            /// <code>$argon2id$v=19$m=<see cref="MEMORY_SIZE"/>,t=<see cref="ARGON2_ITERATIONS"/>,p=<see cref="DEGREE_OF_PARALLELISM"/>$<see cref="SALT_LENGTH"/>salt$<see cref="KEY_LENGTH"/>passwordhash</code>
            /// <para>Argon2 is specifically designed for user passwords that DO NOT meet the following requirements:</para>
            /// <list type="bullet">
            ///     <item><description>ASCII printable uniformly random passwords of length 20 or more (6.6 bits/character)</description></item>
            ///     <item><description>PINs of 40-digit length or more, chosen uniformly at random (3.3 bits/digit)</description></item>
            ///     <item><description>11-word or longer Diceware passphrases (12.9 bits/word)</description></item>
            ///     <item><description>12-word or longer XKCD "correct battery horse staple ..." passwords (2,048 word dictionary, words chosen uniformly at random)</description></item>
            /// </list>
            /// </summary>
            /// <param name="plaintextPassword">The given password to hash using the Argon2id hashing technique.</param>
            /// <param name="salt">The salt to mix with the hash; default salt value length is <see cref="SALT_LENGTH"/> bytes.</param>
            /// <param name="parallelism">(Optional) The amount of threads, <b>h</b>, to use; default value is <see cref="DEGREE_OF_PARALLELISM"/> threads.</param>
            /// <param name="iterations">(Optional) The number of iterations, <b>t</b>, to use; default value is <see cref="ARGON2_ITERATIONS"/> iterations.</param>
            /// <param name="memorySize">(Optional) The size of memory, <b>m</b>, to use in KB; default value is <see cref="MEMORY_SIZE"/> KB.</param>
            /// <returns>An Argon2id hashed encoding of the given input string.</returns>
            public static string CalculateArgon2idHash(string plaintextPassword, byte[] salt, int? parallelism = null, int iterations = ARGON2_ITERATIONS, int memorySize = MEMORY_SIZE)
            {
                parallelism = parallelism ?? DEGREE_OF_PARALLELISM;
                salt = salt ?? GenerateSalt();

                string encodedPassword;

                using (Argon2id argon2 = new Argon2id(Encoding.UTF8.GetBytes(plaintextPassword)))
                {
                    argon2.DegreeOfParallelism = (int)parallelism;
                    argon2.Iterations = iterations;
                    argon2.MemorySize = memorySize;
                    argon2.Salt = salt;

                    encodedPassword = string.Format("${0}$v={1}$m={2},t={3},p={4}${5}${6}",
                        argon2.GetType().Name.ToLower(),
                        ARGON2_VERSION,
                        argon2.MemorySize,
                        argon2.Iterations,
                        argon2.DegreeOfParallelism,
                        Convert.ToBase64String(argon2.Salt),
                        Convert.ToBase64String(argon2.GetBytes(KEY_LENGTH))
                    );
                }

                return encodedPassword;
            }

            /// <summary>
            /// Calculates the bCrypt "$2y" (Corrected) hash for a given string, using random salt and <see cref="BCRYPT_ITERATIONS"/> rounds of iteration.
            /// Output is then encoded in the following format for storage: 
            /// <code>$2y$<see cref="BCRYPT_ITERATIONS"/>$TwentytwocharactersaltThirtyonecharacterspasswordhash</code>
            /// </summary>
            /// <param name="plaintextPassword">The given password to hash using the bCrypt hashing technique.</param>
            /// <returns>A bCrypt hashed encoding of the given input string.</returns>
            public static string CalculateBCryptHash(string plaintextPassword) {
                return CalculateBCryptHash(plaintextPassword, Crypter.Blowfish.GenerateSalt(BCRYPT_OPTIONS));
            }

            /// <summary>
            /// Calculates the bCrypt "$2y" (Corrected) hash for a given string, using a given
            /// Crypter.Blowfish.GenerateSalt() salt and <see cref="BCRYPT_ITERATIONS"/> rounds of iteration.
            /// Output is then encoded in the following format for storage: 
            /// <code>$2y$<see cref="BCRYPT_ITERATIONS"/>$TwentytwocharactersaltThirtyonecharacterspasswordhash</code>
            /// </summary>
            /// <param name="plaintextPassword">The given password to hash using the bCrypt hashing technique.</param>
            /// <param name="salt">The given salt to mix with the hash; using the Crypter.Blowfish.GenerateSalt() method.</param>
            /// <returns>A bCrypt hashed encoding of the given input string.</returns>
            public static string CalculateBCryptHash(string plaintextPassword, string salt) {
                return Crypter.Blowfish.Crypt(plaintextPassword, salt);
            }

            /// <summary>
                /// Calculates the sCrypt hash using predefined parameters tailored specifically for password hashing and storage.
                /// Output is then encoded in the following format for storage: 
                /// <code>$s2$<see cref="MEMORY_SIZE"/>$<see cref="SCRYPT_ITERATIONS"/>$<see cref="DEGREE_OF_PARALLELISM"/>$<see cref="SALT_LENGTH"/>salt$<see cref="KEY_LENGTH"/>passwordhash</code>
                /// </summary>
                /// <param name="plaintextPassword">The given password to hash using the sCrypt hashing technique.</param>
                /// <returns>An sCrypt hashed encoding of the given input string.</returns>
            public static string CalculateSCryptHash(string plaintextPassword)
            {
                return CalculateSCryptHash(plaintextPassword, GenerateSalt());
            }

            /// <summary>
            /// Calculates the sCrypt hash using the given parameters tailored specifically for password hashing and storage.
            /// Output is then encoded in the following format for storage: 
            /// <code>$s2$<see cref="MEMORY_SIZE"/>$<see cref="SCRYPT_ITERATIONS"/>$<see cref="DEGREE_OF_PARALLELISM"/>$<see cref="SALT_LENGTH"/>salt$<see cref="KEY_LENGTH"/>passwordhash</code>
            /// </summary>
            /// <param name="plaintextPassword">The given password to hash using the sCrypt hashing technique.</param>
            /// <param name="salt">The salt to mix with the hash; default salt value length is <see cref="SALT_LENGTH"/>.</param>
            /// <param name="cost">(Optional) The memory size or work factor cost, <b>N</b>, to use; default value is <see cref="MEMORY_SIZE"/>.</param>
            /// <param name="blockSize">(Optional) The BlockMix iteration, <b>r</b>, to use; default value is <see cref="SCRYPT_ITERATIONS"/>.</param>
            /// <param name="parallelism">(Optional) The amount of threads, <b>p</b>, to use; default value is <see cref="DEGREE_OF_PARALLELISM"/> threads.</param>
            /// <returns>An sCrypt hashed encoding of the given input string.</returns>
            public static string CalculateSCryptHash(string plaintextPassword, byte[] salt, int? cost = null, int? blockSize = null, int? parallelism = null)
            {
                salt = salt ?? GenerateSalt();
                cost = cost ?? MEMORY_SIZE;
                blockSize = blockSize ?? SCRYPT_ITERATIONS;
                parallelism = parallelism ?? DEGREE_OF_PARALLELISM;

                byte[] scrypt = SCrypt.ComputeDerivedKey(
                key: Encoding.UTF8.GetBytes(plaintextPassword),
                salt: salt,
                cost: (int)cost,
                blockSize: (int)blockSize,
                parallel: (int)parallelism,
                maxThreads: null,
                derivedKeyLength: KEY_LENGTH);

                return string.Format("${0}${1}${2}${3}${4}${5}",
                    "s2",
                    cost,
                    blockSize,
                    parallelism,
                    Convert.ToBase64String(salt),
                    Convert.ToBase64String(scrypt)
                );
            }

            /// <summary>
            /// Checks a plaintext password against the possible Argon2 hashed string version.
            /// </summary>
            /// <param name="plaintextPassword">The given password to check.</param>
            /// <param name="encodedPassword">The Argon2 hashed string of the given password to check against. Encoded strings should be in format <c>$argon2i$v=19$m=65536,t=2,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG</c></param>
            /// <returns><c>True</c> if the plaintext password matches the supplied Argon2 hashed password; otherwise <c>False</c>.</returns>
            public static bool CheckArgon2Hash(string plaintextPassword, string encodedPassword)
            {
                if (!encodedPassword.StartsWith("$argon2")) { throw new ArgumentOutOfRangeException(nameof(encodedPassword), @"Not a valid Argon2 encoding. Encoding should start with '$argon2'."); }

                Match match = ARGON2_REGEX.Match(encodedPassword);

                if (!match.Success) { throw new ArgumentOutOfRangeException(nameof(encodedPassword), @"Not a valid Argon2 encoding."); }
                if (match.Groups["version"].Value != ARGON2_VERSION) { throw new NotSupportedException(string.Format("The Argon2 version supplied (v{0}) is not supported.", match.Groups["version"].Value)); }

                string variant = match.Groups["variant"].Value;
                int version = Convert.ToInt32(match.Groups["version"].Value);
                int parallelism = Convert.ToInt32(match.Groups["parallelism"].Value);
                int iterations = Convert.ToInt32(match.Groups["iterations"].Value);
                int memorySize = Convert.ToInt32(match.Groups["memory"].Value);
                byte[] salt = Convert.FromBase64String(match.Groups["salt"].Value);

                switch (variant) {
                    case "argon2d": return encodedPassword == CalculateArgon2dHash(plaintextPassword, salt, parallelism, iterations, memorySize);
                    case "argon2id": return encodedPassword == CalculateArgon2idHash(plaintextPassword, salt, parallelism, iterations, memorySize);
                    case "argon2i":
                    case "argon2": return encodedPassword == CalculateArgon2iHash(plaintextPassword, salt, parallelism, iterations, memorySize);

                    default: throw new NotSupportedException(string.Format("The given Argon2 type ({0}) is not supported", variant));
                }
            }

            /// <summary>
            /// Checks a plaintext password against the possible bCrypt hashed string version.
            /// </summary>
            /// <param name="plaintextPassword">The given password to check.</param>
            /// <param name="encodedPassword">The bCrypt hashed string of the given password to check against.</param>
            /// <returns><c>True</c> if the plaintext password matches the supplied bCrypt hashed password; otherwise <c>False</c>.</returns>
            public static bool CheckBCryptHash(string plaintextPassword, string encodedPassword)
            {
                return Crypter.CheckPassword(plaintextPassword, encodedPassword);
            }

            /// <summary>
            /// Checks a plaintext password against the possible sCrypt hashed string version.
            /// </summary>
            /// <param name="plaintextPassword">The given password to check.</param>
            /// <param name="encodedPassword">The sCrypt hashed string of the given password to check against.</param>
            /// <returns><c>True</c> if the plaintext password matches the supplied sCrypt hashed password; otherwise <c>False</c>.</returns>
            public static bool CheckSCryptHash(string plaintextPassword, string encodedPassword)
            {
                if (!encodedPassword.StartsWith("$s2$")) { throw new ArgumentOutOfRangeException(nameof(encodedPassword), @"Not a valid sCrypt encoding. Encoding should start with '$s2'."); }

                Match match = SCRYPT_REGEX.Match(encodedPassword);

                if (!match.Success) { throw new ArgumentOutOfRangeException(nameof(encodedPassword), @"Not a valid sCrypt encoding."); }

                int cost = Convert.ToInt32(match.Groups["cost"].Value);
                int blockSize = Convert.ToInt32(match.Groups["blocksize"].Value);
                int parallelism = Convert.ToInt32(match.Groups["parallelism"].Value);
                byte[] salt = Convert.FromBase64String(match.Groups["salt"].Value);

                return encodedPassword == CalculateSCryptHash(plaintextPassword, salt, cost, blockSize, parallelism);
            }

            /// <summary>
            /// Generates an N-sized byte salt using the System.Security.Cryptography RNGCryptoServiceProvider.
            /// </summary>
            /// <param name="size">(Optional) The size, in bytes, of the salt to generate. Default is <see cref="SALT_LENGTH"/> bytes 
            ///     —the recommended size for Argon2, BCrypt, and SCrypt hashing.</param>
            /// <returns>A byte[] value consisting of a randomly generated, cryptographically secure salt.</returns>
            public static byte[] GenerateSalt(int size = SALT_LENGTH)
            {
                if (size < 1) { throw new ArgumentOutOfRangeException(nameof(size), @"Please choose a positive integer value for the salt size."); }

                RNGCryptoServiceProvider rng = new RNGCryptoServiceProvider();
                byte[] salt = new byte[size];
                rng.GetBytes(salt);

                return salt;
            }

            /// <summary>
            /// Returns a user-friendly message describing the compliance status of a password according to <a href="https://pages.nist.gov/800-63-3/sp800-63b.html">NIST SP800-63B</a> guidelines.
            /// </summary>
            /// <param name="status">The <c>PasswordStatus</c> object holding the password status.</param>
            /// <returns>A user-friendly message describing the security compliance of a password according to NIST SP800-63B guidelines.</returns>
            public static string GetPasswordStatusMessage(PasswordStatus status)
            {
                switch (status) {
                    case PasswordStatus.Secure:
                        return @"Password is considered ""secure"" according to NIST SP800-63B guidelines.";
                    case PasswordStatus.OKPendingPersonalInfo:
                        return @"Password is considered OK, pending verification against personal data from the password's owner.";
                    case PasswordStatus.BadBlacklisted:
                        return @"Password is insecure because it is considered common or is blacklisted.";
                    case PasswordStatus.BadContainsPersonalInfo:
                        return @"Password is insecure because it contains personal information from the password's owner.";
                    case PasswordStatus.BadInvalidLength:
                        return @"Password is insecure because it does not meet the character length requirements. Passwords should be of length between " +
                            $"({PASSWORD_MIN_LENGTH}) and ({PASSWORD_MAX_LENGTH}) characters, inclusive.";
                    case PasswordStatus.BadInvalidComposition:
                        return $"Password is insecure because its composition contains more than ({PASSWORD_REPEAT_CHARS_ALLOWED}) consecutively-repeated " +
                            $"characters, or repeated phrases of length greater than ({PASSWORD_REPEAT_PHRASELENGTH_ALLOWED}) characters.";

                    case PasswordStatus.Unknown:
                    default:
                        return @"An unknown password status has occurred.";
                }
            }

            /// <summary>
            /// Using a word dictionary and the SecList blacklisted-passwords database, will determine 
            /// if the password provided is common/blacklisted and should be considered insecure.
            /// </summary>
            /// <param name="plaintextPassword">The given password to check for compliance.</param>
            /// <returns><c>True</c> if the given password has been found to be blacklisted (insecure); otherwise <c>False</c>.</returns>
            public static bool IsPasswordBlacklisted(string plaintextPassword)
            {
                return PASSWORD_BLACKLIST.Contains(plaintextPassword);
            }

            /// <summary>
            /// Determines if a password contains personally identifiable information (PII). Matches are
            /// determined based on value provided for <see cref="PASSWORD_MIN_PII_LENGTH"/>. Additional checks to
            /// make sure the entire password is not contained within PII and vice versa.
            /// </summary>
            /// <param name="plaintextPassword">The given password to check for compliance.</param>
            /// <param name="personalInfo">(Optional) A list of string parameters that contain personal information about the 
            ///     password's owner (e.g. FirstName, LastName, EmailAddress, etc). This personal information will be 
            ///     checked against the password to ensure that the password does not contain these values.</param>
            /// <returns><c>True</c> if the given password has been found to be personally identifiable (insecure); otherwise <c>False</c>.</returns>
            public static bool IsPasswordPersonallyIdentifiable(string plaintextPassword, params string[] personalInfo)
            {
                if (personalInfo == null || personalInfo.Length == 0) { throw new ArgumentNullException(nameof(personalInfo), @"Personal information must be provided to determine password compliance."); }

                for (int i = 0; i < personalInfo.Length; i++) {
                    if (personalInfo[i] == null) { continue; }

                    // if email address provided, only care about the local part and not the domain
                    if (REGEX_EMAIL.IsMatch(personalInfo[i])) {
                        personalInfo[i] = personalInfo[i].Substring(0, personalInfo[i].IndexOf("@", StringComparison.OrdinalIgnoreCase)); 
                    }

                    if ((personalInfo[i].Length >= PASSWORD_MIN_PII_LENGTH) &&
                        ((plaintextPassword.IndexOf(personalInfo[i], StringComparison.OrdinalIgnoreCase) != -1) ||
                        (personalInfo[i].IndexOf(plaintextPassword, StringComparison.OrdinalIgnoreCase) != -1))) {
                        return true;
                    }
                }

                return false;
            }

            /// <summary>
            /// Using regular expressions, determines if the password contains repetitive letters or phrases. Matches are determined
            /// based on values provided for <see cref="PASSWORD_REPEAT_CHARS_ALLOWED"/> and <see cref="PASSWORD_REPEAT_PHRASELENGTH_ALLOWED"/>.
            /// </summary>
            /// <param name="plaintextPassword">The given password to check for compliance.</param>
            /// <returns><c>True</c> if the given password has been found to be repetitive (insecure); otherwise <c>False</c>.</returns>
            public static bool IsPasswordRepetitive(string plaintextPassword) {
                return REGEX_LETTER_REPETITION.IsMatch(plaintextPassword) || REGEX_WORD_REPETITION.IsMatch(plaintextPassword);
            }

            /// <summary>
            /// Determines if the password should be considered "secure" by ensuring the password is between (<see cref="Security.Utilities.PASSWORD_MIN_LENGTH"/>) and
            /// (<see cref="Security.Utilities.PASSWORD_MAX_LENGTH"/>) characters long, inclusive, then verifying the password against a blacklist database of bad/compromised 
            /// passwords. Finally, if personal information parameters were supplied, the method checks to ensure no personal information
            /// was used to create the password.
            /// </summary>
            /// <param name="plaintextPassword">The given password to check for compliance.</param>
            /// <param name="status">The status describing why the password is considered secure or insecure.</param>
            /// <param name="personalInfo">(Optional) A list of string parameters that contain personal information about the 
            ///     password's owner (e.g. FirstName, LastName, EmailAddress, etc). This personal information will be 
            ///     checked against the password to ensure that the password does not contain these values.</param>
            /// <returns><c>True</c> if the password should be considered "secure" to use; otherwise <c>False</c>. Additionally sets the 
            ///     <c>PasswordStatus</c> object describing why the password is considered secure or insecure.</returns>
            /// <remarks>If no personal information is supplied, the method may still return <c>True</c> but with a 
            ///     <c>PasswordStatus</c> return value type of <c>PasswordStatus.OKPendingPersonalInfo</c>.</remarks>
            public static bool IsPasswordSecure(string plaintextPassword, out PasswordStatus status, params string[] personalInfo)
            {
                status = PasswordStatus.Unknown;

                if (plaintextPassword == null || plaintextPassword.Length < PASSWORD_MIN_LENGTH || plaintextPassword.Length > PASSWORD_MAX_LENGTH) {
                    status = PasswordStatus.BadInvalidLength;

                } else if (IsPasswordBlacklisted(plaintextPassword)) {
                    status = PasswordStatus.BadBlacklisted;

                } else if (IsPasswordRepetitive(plaintextPassword)) {
                    status = PasswordStatus.BadInvalidComposition;

                } else if (personalInfo == null || personalInfo.Length == 0) {
                    status = PasswordStatus.OKPendingPersonalInfo;

                } else if (IsPasswordPersonallyIdentifiable(plaintextPassword, personalInfo)) {
                    status = PasswordStatus.BadContainsPersonalInfo;

                } else {
                    status = PasswordStatus.Secure;
                }

                return status == PasswordStatus.OKPendingPersonalInfo || status == PasswordStatus.Secure;
            }
        }
    }
}

Questions / Critique / Thinking Out Loud

  • I've created an enum value PasswordStatus.OKPendingPersonalInfo that will still allow a password to be considered "secure" with a caveat that no personal information was given to test against the password. Does this gracefully handle a valid edge case where no personal information exists?
  • Code Review
    • Class-Subclass vs One Big Class
    • Proper field/method accessibility
    • enum usage/placement
    • params vs explicit string[] for personal information parameters.
    • Superfluous comments
    • Exception handling
\$\endgroup\$
  • 1
    \$\begingroup\$ The password guidelines say that the password length should not have an upper limit, and if you choose to use an upper limit (which unnecessarily restricts the users), it should at least be 64. Why did you choose to restrict the password? \$\endgroup\$ – Roland Illig May 29 at 6:00
  • \$\begingroup\$ @RolandIllig In short: no reason. The implementation was to simply meet requirements for the NIST guidelines. When creating the helper methods to test the password for compliancy, I decided to add a maximum length to the already-necessary minimum length check. \$\endgroup\$ – outkst May 31 at 14:20
3
\$\begingroup\$

Service Provider Interface

Your helper class is full with switch statements to dispatch operations to the underlying algorithm used. You could use the service provider interface, rather than recurrent switch blocks.

public static bool CheckPassword(string plaintextPassword, string encodedPassword)
        {
            if (!plaintextPassword.IsNormalized()) { plaintextPassword = plaintextPassword.Normalize(); }

            if (encodedPassword.StartsWith("$argon2")) {
                return Utilities.CheckArgon2Hash(plaintextPassword, encodedPassword);
            }

            if (encodedPassword.StartsWith("$2y")) {
                return Utilities.CheckBCryptHash(plaintextPassword, encodedPassword);
            }

            if (encodedPassword.StartsWith("$s2")) {
                return Utilities.CheckSCryptHash(plaintextPassword, encodedPassword);
            }

            throw new NotSupportedException(@"The given hashed password technique could not be determined.");
        }

Could be rewritten to dispatch to the service provider.

    public static bool CheckPassword(string plaintextPassword, string encodedPassword)
    {
          if (!plaintextPassword.IsNormalized()) { plaintextPassword = plaintextPassword.Normalize(); }

          this.GetServiceProvider(encodedPassword).Verify(plaintextPassword, encodedPassword);
    }

Retrieving the service provider might use your existing code. It is good practice to allow also registering other service providers. You could store all registered in a dictionary providers. HashTechnique could become a convenience enum for default techniques, but I would allow an overload on string to register additional techniques.

protected virtual IPasswordServiceProvider GetServiceProvider(string digest) 
{
    if (digest.StartsWith("$argon2")) {
        return this.providers[PasswordServiceProvider.Argon2];
    // and so on ..
}

Service Provider Implementation

Rather than calling code from a helper like Utilities.CheckBCryptHash, I would let each service provider instance implement an interface and have their own methods.

public interface IPasswordServiceProvider {
    string Name { get; } // argon2, bcrypt, scrypt, ..
    bool Verify(string plainText, string digest);
}

public abstract class PasswordServiceProvider : IPasswordServiceProvider {
    // you could keep track of common provider names
    public const string Argon2 = "Argon2";
    // .. add shared logic
}

public class Argon2PasswordServiceProvider : PasswordServiceProvider  {
    public override string Name => PasswordServiceProvider.Argon2;
    public override bool Verify(string plainText, string digest) {
        // perform argon2 algorithm ..
    }
}

Security Considerations

Try implementing SlowEquals rather than a ReferenceEquals.

return encodedPassword == CalculateArgon2dHash(plaintextPassword, ..);

\$\endgroup\$
  • \$\begingroup\$ Have you had a chace to use the service-provider interface in real applications? I know the theory is sometimes tempting but I always thought this pattern is an overkill. \$\endgroup\$ – t3chb0t May 29 at 6:50
  • \$\begingroup\$ @t3chb0t I have worked on frameworks where this pattern is used. The majority of such patterns are implemented when there are a fixed set of providers available. In modern APIs, I have not seen this pattern used that much anymore. I would consider a password manager still a good candidate for this pattern. \$\endgroup\$ – dfhwze May 29 at 6:52
  • 1
    \$\begingroup\$ I think this pattern was probably more popular when such tools as Autofac weren't there yet; Nowadays the IIndex<TKey, TService> does this job pretty well too, or other Keyed dependencies. \$\endgroup\$ – t3chb0t May 29 at 6:54

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.