Generator haseł z możliwością parametryzacji

21.11.2011

W końcu nadszedł ten dzień, kiedy metoda Membership.GeneratePassword przestała mi wystarczać. Zawiedziony możliwościami tego generatora napisałem poniższy kawałek kodu:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;

namespace Common.Security
{
    public interface IPasswordGenerator
    {
        string Generate(int length);
        string Generate(int length, PasswordOption options);
    }

    public class PasswordGenerator : IPasswordGenerator
    {
        public static readonly char[] LowerCaseArr ="abcdefghijklmnopqrstuvwxyz".ToCharArray();
        public static readonly char[] UpperCaseArr = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".ToCharArray();
        public static readonly char[] DigitsArr = "0123456789".ToCharArray();
        public static readonly char[] MinusArr = new[] { '-' };
        public static readonly char[] UnderlinArr = new[] { '_' };
        public static readonly char[] SpaceArr = new[] { ' ' };
        public static readonly char[] SpecialArr = "!\"#$%&'*+./:;=?@\\^".ToCharArray();
        public static readonly char[] BracketArr = "[]{}()<>".ToCharArray();

        private Dictionary<PasswordOption, char[]> _charDict = new Dictionary<PasswordOption, char[]>
                                                                   {
                                                                       {PasswordOption.LowerCase, LowerCaseArr},
                                                                       {PasswordOption.UpperCase, UpperCaseArr},
                                                                       {PasswordOption.Digits, DigitsArr},
                                                                       {PasswordOption.Minus, MinusArr},
                                                                       {PasswordOption.Underline, UnderlinArr},
                                                                       {PasswordOption.Space, SpaceArr},
                                                                       {PasswordOption.Special, SpecialArr},
                                                                       {PasswordOption.Brackets, BracketArr}
                                                                   };

        public string Generate(int length)
        {
            if (length < 1 || length > 128)
            {
                throw new ArgumentOutOfRangeException("length");
            }

            return GenerateImpl(length, PasswordOption.LowerCase | PasswordOption.UpperCase | PasswordOption.Digits | PasswordOption.Special);
        }

        public string Generate(int length, PasswordOption options)
        {
            if (length < 1 || length > 128)
            {
                throw new ArgumentOutOfRangeException("length");
            }

            return GenerateImpl(length, options);
        }

        private string GenerateImpl(int length, PasswordOption option)
        {
            var charset = PrepareCharset(option);

            var random = new RNGCryptoServiceProvider();
            var buffer = new byte[length * 2];
            random.GetBytes(buffer);

            var arr = Enumerable.Range(0, charset.Length).ToArray();
            arr.Shuffle();

            int j = 0;
            var password = new char[length];
            for (int i = 0; i < length; i++)
            {
                // na początku spełnij warunek "co najmniej jeden znak danego typu"
                if (i < arr.Length)
                {
                    var k = arr[i];
                    j++;
                    var l = buffer[j] % charset[k].Length;
                    password[i] = charset[k][l];
                    continue;
                }

                var charsetIndex = buffer[j] % charset.Length;
                j++;

                var charIndex = buffer[j] % charset[charsetIndex].Length;
                j++;

                password[i] = charset[charsetIndex][charIndex];
            }

            return new string(password);
        }

        private char[][] PrepareCharset(PasswordOption option)
        {
            return _charDict.Where(x => option.IsSet(x.Key)).Select(x => x.Value).ToArray();
        }
    }

    public static class EnumExtensions
    {
        public static bool IsSet(this Enum input, Enum matchTo)
        {
            return (Convert.ToUInt32(input) & Convert.ToUInt32(matchTo)) != 0;
        } 
    }

    public static class ArrayExtensions
    {
        public static void Shuffle<T>(this T[] array)
        {
            var rng = new Random();
            int n = array.Length;
            while (n > 1)
            {
                int k = rng.Next(n);
                n--;
                T temp = array[n];
                array[n] = array[k];
                array[k] = temp;
            }
        }
    }
}

Ta prosta klasa pozwala na generowanie haseł o określonej długości i składających się z kombinacji znaków z określonych zbiorów (małe litery, duże litery, cyfry, znaki specjalne, nawiasy, spacja, podkreślenie, znaki specjalne, minus). Zbiory, na podstawie których generowane jest hasło określa parametr PasswordOption. Dozwolone jest łączenie zbiorów znaków źródłowych hasła (np. małe litery + duże litery + nawiasy + minus) przez kombinację odpowiadających im flag PasswordOption:

passGen.Generate(length, PasswordOption.LowerCase | PasswordOption.UpperCase | PasswordOption.Digits | PasswordOption.Special);

Hasło może mieć od 1 do 128 znaków. Dla fanatyków bezpieczeństwa przewiduję wersję generującą hasła o długości do 2048 znaków Uśmiech  Na koniec kilka testów jednostkowych do tego generatora.

namespace Common.Tests.Security
{
    public class PasswordGeneratorTest
    {
        [Fact]
        public void Generate_CheckUniqueness()
        {
            var passGen = new PasswordGenerator();

            // lame
            var set = new HashSet<string>();
            for (int i = 0; i < 1000; i++)
            {
                set.Add(passGen.Generate(10));
            }

            Assert.Equal(1000, set.Count); // dzięki Marcin :)
        }

        [Fact]
        public void Generate_OnlyUpperCase()
        {
            var passGen = new PasswordGenerator();
            var pass = passGen.Generate(10, PasswordOption.UpperCase);
            Console.WriteLine(pass);
            Assert.True(CheckCharSubset(pass, PasswordGenerator.UpperCaseArr));
        }

        [Fact]
        public void Geterate_OnlyLowerCase()
        {
            var passGen = new PasswordGenerator();
            var pass = passGen.Generate(10, PasswordOption.LowerCase);
            Console.WriteLine(pass);
            Assert.True(CheckCharSubset(pass, PasswordGenerator.LowerCaseArr));
        }

        [Fact]
        public void Generate_OnlyDigits()
        {
            var passGen = new PasswordGenerator();
            var pass = passGen.Generate(10, PasswordOption.Digits);
            Console.WriteLine(pass);
            Assert.True(CheckCharSubset(pass, PasswordGenerator.DigitsArr));
        }

        [Fact]
        public void Generate_OnlySpecial()
        {
            var passGen = new PasswordGenerator();
            var pass = passGen.Generate(10, PasswordOption.Special);
            Console.WriteLine(pass);
            Assert.True(CheckCharSubset(pass, PasswordGenerator.SpecialArr));
        }


        [Fact]
        public void Generate_LowerAndUpperCase()
        {
            var passGen = new PasswordGenerator();
            var pass = passGen.Generate(20, PasswordOption.LowerCase | PasswordOption.UpperCase);
            Console.WriteLine(pass);
            Assert.Equal(20, pass.Length);
            Assert.True(CheckCharSubset(pass, PasswordGenerator.LowerCaseArr, PasswordGenerator.UpperCaseArr));
        }

        [Fact]
        public void Generate_LowerUpperCaseAndSpecial()
        {
            var passGen = new PasswordGenerator();
            var pass = passGen.Generate(20, PasswordOption.LowerCase | PasswordOption.UpperCase | PasswordOption.Special);
            Console.WriteLine(pass);
            Assert.Equal(20, pass.Length);
            Assert.True(CheckCharSubset(pass, PasswordGenerator.LowerCaseArr, PasswordGenerator.UpperCaseArr, PasswordGenerator.SpecialArr));
        }

        [Fact]
        public void Generate_LowerUpperCaseAndSpecialAndBrackets()
        {
            var passGen = new PasswordGenerator();
            var pass = passGen.Generate(20, PasswordOption.LowerCase | PasswordOption.UpperCase | PasswordOption.Special | PasswordOption.Brackets);
            Console.WriteLine(pass);
            Assert.Equal(20, pass.Length);
            Assert.True(CheckCharSubset(pass, PasswordGenerator.LowerCaseArr, PasswordGenerator.UpperCaseArr, PasswordGenerator.SpecialArr, PasswordGenerator.BracketArr));
        }

        public bool CheckCharSubset(IEnumerable<char> subSet, params IEnumerable<char>[] sets)
        {
            var set = sets.SelectMany(x => x).ToArray();
            var enumerable = subSet.Except(set);
            return !enumerable.Any();
        }
    }
}