Quellcode durchsuchen

Remove temporary allocations from Identity (#44557)

Brennan vor 3 Jahren
Ursprung
Commit
c5ee07392f

+ 2 - 2
src/Identity/Extensions.Core/src/AuthenticatorTokenProvider.cs

@@ -68,9 +68,9 @@ public class AuthenticatorTokenProvider<TUser> : IUserTwoFactorTokenProvider<TUs
         for (int i = -2; i <= 2; i++)
         {
 #if NET6_0_OR_GREATER
-            var expectedCode = Rfc6238AuthenticationService.ComputeTotp(keyBytes, (ulong)(timestep + i), modifier: null);
+            var expectedCode = Rfc6238AuthenticationService.ComputeTotp(keyBytes, (ulong)(timestep + i), modifierBytes: null);
 #else
-            var expectedCode = Rfc6238AuthenticationService.ComputeTotp(hash, (ulong)(timestep + i), modifier: null);
+            var expectedCode = Rfc6238AuthenticationService.ComputeTotp(hash, (ulong)(timestep + i), modifierBytes: null);
 #endif
             if (expectedCode == code)
             {

+ 37 - 1
src/Identity/Extensions.Core/src/Base32.cs

@@ -2,6 +2,7 @@
 // The .NET Foundation licenses this file to you under the MIT license.
 
 using System;
+using System.Security.Cryptography;
 using System.Text;
 
 namespace Microsoft.AspNetCore.Identity;
@@ -11,6 +12,41 @@ internal static class Base32
 {
     private const string _base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
 
+#if NET6_0_OR_GREATER
+    public static string GenerateBase32()
+    {
+        const int length = 20;
+        // base32 takes 5 bytes and converts them into 8 characters, which would be (byte length / 5) * 8
+        // except that it also pads ('=') for the last processed chunk if it's less than 5 bytes.
+        // So in order to handle the padding we add 1 less than the chunk size to our byte length
+        // which will either be removed due to integer division truncation if the length was already a multiple of 5
+        // or it will increase the divided length by 1 meaning that a 1-4 byte length chunk will be 1 instead of 0
+        // so the padding is now included in our string length calculation
+        return string.Create(((length + 4) / 5) * 8, 0, static (buffer, _) =>
+        {
+            Span<byte> bytes = stackalloc byte[length];
+            RandomNumberGenerator.Fill(bytes);
+
+            var index = 0;
+            for (int offset = 0; offset < bytes.Length;)
+            {
+                byte a, b, c, d, e, f, g, h;
+                int numCharsToOutput = GetNextGroup(bytes, ref offset, out a, out b, out c, out d, out e, out f, out g, out h);
+
+                buffer[index + 7] = ((numCharsToOutput >= 8) ? _base32Chars[h] : '=');
+                buffer[index + 6] = ((numCharsToOutput >= 7) ? _base32Chars[g] : '=');
+                buffer[index + 5] = ((numCharsToOutput >= 6) ? _base32Chars[f] : '=');
+                buffer[index + 4] = ((numCharsToOutput >= 5) ? _base32Chars[e] : '=');
+                buffer[index + 3] = ((numCharsToOutput >= 4) ? _base32Chars[d] : '=');
+                buffer[index + 2] = (numCharsToOutput >= 3) ? _base32Chars[c] : '=';
+                buffer[index + 1] = (numCharsToOutput >= 2) ? _base32Chars[b] : '=';
+                buffer[index] = (numCharsToOutput >= 1) ? _base32Chars[a] : '=';
+                index += 8;
+            }
+        });
+    }
+#endif
+
     public static string ToBase32(byte[] input)
     {
         if (input == null)
@@ -84,7 +120,7 @@ internal static class Base32
     }
 
     // returns the number of bytes that were output
-    private static int GetNextGroup(byte[] input, ref int offset, out byte a, out byte b, out byte c, out byte d, out byte e, out byte f, out byte g, out byte h)
+    private static int GetNextGroup(Span<byte> input, ref int offset, out byte a, out byte b, out byte c, out byte d, out byte e, out byte f, out byte g, out byte h)
     {
         uint b1, b2, b3, b4, b5;
 

+ 10 - 0
src/Identity/Extensions.Core/src/IdentityResult.cs

@@ -49,6 +49,16 @@ public class IdentityResult
         return result;
     }
 
+    internal static IdentityResult Failed(List<IdentityError>? errors)
+    {
+        var result = new IdentityResult { Succeeded = false };
+        if (errors != null)
+        {
+            result._errors.AddRange(errors);
+        }
+        return result;
+    }
+
     /// <summary>
     /// Converts the value of the current <see cref="IdentityResult"/> object to its equivalent string representation.
     /// </summary>

+ 3 - 1
src/Identity/Extensions.Core/src/PasswordHasher.cs

@@ -35,13 +35,15 @@ public class PasswordHasher<TUser> : IPasswordHasher<TUser> where TUser : class
     private readonly int _iterCount;
     private readonly RandomNumberGenerator _rng;
 
+    private static readonly PasswordHasherOptions DefaultOptions = new PasswordHasherOptions();
+
     /// <summary>
     /// Creates a new instance of <see cref="PasswordHasher{TUser}"/>.
     /// </summary>
     /// <param name="optionsAccessor">The options for this instance.</param>
     public PasswordHasher(IOptions<PasswordHasherOptions>? optionsAccessor = null)
     {
-        var options = optionsAccessor?.Value ?? new PasswordHasherOptions();
+        var options = optionsAccessor?.Value ?? DefaultOptions;
 
         _compatibilityMode = options.CompatibilityMode;
         switch (_compatibilityMode)

+ 10 - 4
src/Identity/Extensions.Core/src/PasswordValidator.cs

@@ -46,36 +46,42 @@ public class PasswordValidator<TUser> : IPasswordValidator<TUser> where TUser :
         {
             throw new ArgumentNullException(nameof(manager));
         }
-        var errors = new List<IdentityError>();
+        List<IdentityError>? errors = null;
         var options = manager.Options.Password;
         if (string.IsNullOrWhiteSpace(password) || password.Length < options.RequiredLength)
         {
+            errors ??= new List<IdentityError>();
             errors.Add(Describer.PasswordTooShort(options.RequiredLength));
         }
         if (options.RequireNonAlphanumeric && password.All(IsLetterOrDigit))
         {
+            errors ??= new List<IdentityError>();
             errors.Add(Describer.PasswordRequiresNonAlphanumeric());
         }
         if (options.RequireDigit && !password.Any(IsDigit))
         {
+            errors ??= new List<IdentityError>();
             errors.Add(Describer.PasswordRequiresDigit());
         }
         if (options.RequireLowercase && !password.Any(IsLower))
         {
+            errors ??= new List<IdentityError>();
             errors.Add(Describer.PasswordRequiresLower());
         }
         if (options.RequireUppercase && !password.Any(IsUpper))
         {
+            errors ??= new List<IdentityError>();
             errors.Add(Describer.PasswordRequiresUpper());
         }
         if (options.RequiredUniqueChars >= 1 && password.Distinct().Count() < options.RequiredUniqueChars)
         {
+            errors ??= new List<IdentityError>();
             errors.Add(Describer.PasswordRequiresUniqueChars(options.RequiredUniqueChars));
         }
         return
-            Task.FromResult(errors.Count == 0
-                ? IdentityResult.Success
-                : IdentityResult.Failed(errors.ToArray()));
+            Task.FromResult(errors?.Count > 0
+                ? IdentityResult.Failed(errors)
+                : IdentityResult.Success);
     }
 
     /// <summary>

+ 25 - 28
src/Identity/Extensions.Core/src/Rfc6238AuthenticationService.cs

@@ -17,21 +17,8 @@ internal static class Rfc6238AuthenticationService
     private static readonly Encoding _encoding = new UTF8Encoding(false, true);
 #if NETSTANDARD2_0 || NETFRAMEWORK
     private static readonly DateTime _unixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
-    private static readonly RandomNumberGenerator _rng = RandomNumberGenerator.Create();
 #endif
 
-    // Generates a new 80-bit security token
-    public static byte[] GenerateRandomKey()
-    {
-        byte[] bytes = new byte[20];
-#if NETSTANDARD2_0 || NETFRAMEWORK
-        _rng.GetBytes(bytes);
-#else
-        RandomNumberGenerator.Fill(bytes);
-#endif
-        return bytes;
-    }
-
     internal static int ComputeTotp(
 #if NET6_0_OR_GREATER
         byte[] key,
@@ -39,19 +26,33 @@ internal static class Rfc6238AuthenticationService
         HashAlgorithm hashAlgorithm,
 #endif
         ulong timestepNumber,
-        string? modifier)
+        byte[]? modifierBytes)
     {
         // # of 0's = length of pin
         const int Mod = 1000000;
 
         // See https://tools.ietf.org/html/rfc4226
         // We can add an optional modifier
+#if NET6_0_OR_GREATER
+        Span<byte> timestepAsBytes = stackalloc byte[sizeof(long)];
+        var res = BitConverter.TryWriteBytes(timestepAsBytes, IPAddress.HostToNetworkOrder((long)timestepNumber));
+        Debug.Assert(res);
+#else
         var timestepAsBytes = BitConverter.GetBytes(IPAddress.HostToNetworkOrder((long)timestepNumber));
+#endif
 
 #if NET6_0_OR_GREATER
-        var hash = HMACSHA1.HashData(key, ApplyModifier(timestepAsBytes, modifier));
+        Span<byte> modifierCombinedBytes = timestepAsBytes;
+        if (modifierBytes is not null)
+        {
+            modifierCombinedBytes = ApplyModifier(timestepAsBytes, modifierBytes);
+        }
+        Span<byte> hash = stackalloc byte[HMACSHA1.HashSizeInBytes];
+        res = HMACSHA1.TryHashData(key, modifierCombinedBytes, hash, out var written);
+        Debug.Assert(res);
+        Debug.Assert(written == hash.Length);
 #else
-        var hash = hashAlgorithm.ComputeHash(ApplyModifier(timestepAsBytes, modifier));
+        var hash = hashAlgorithm.ComputeHash(ApplyModifier(timestepAsBytes, modifierBytes));
 #endif
 
         // Generate DT string
@@ -65,16 +66,10 @@ internal static class Rfc6238AuthenticationService
         return binaryCode % Mod;
     }
 
-    private static byte[] ApplyModifier(byte[] input, string? modifier)
+    private static byte[] ApplyModifier(Span<byte> input, byte[] modifierBytes)
     {
-        if (string.IsNullOrEmpty(modifier))
-        {
-            return input;
-        }
-
-        var modifierBytes = _encoding.GetBytes(modifier);
         var combined = new byte[checked(input.Length + modifierBytes.Length)];
-        Buffer.BlockCopy(input, 0, combined, 0, input.Length);
+        input.CopyTo(combined);
         Buffer.BlockCopy(modifierBytes, 0, combined, input.Length, modifierBytes.Length);
         return combined;
     }
@@ -100,12 +95,13 @@ internal static class Rfc6238AuthenticationService
         // Allow a variance of no greater than 9 minutes in either direction
         var currentTimeStep = GetCurrentTimeStepNumber();
 
+        var modifierBytes = modifier is not null ? _encoding.GetBytes(modifier) : null;
 #if NET6_0_OR_GREATER
-        return ComputeTotp(securityToken, currentTimeStep, modifier);
+        return ComputeTotp(securityToken, currentTimeStep, modifierBytes);
 #else
         using (var hashAlgorithm = new HMACSHA1(securityToken))
         {
-            return ComputeTotp(hashAlgorithm, currentTimeStep, modifier);
+            return ComputeTotp(hashAlgorithm, currentTimeStep, modifierBytes);
         }
 #endif
     }
@@ -124,12 +120,13 @@ internal static class Rfc6238AuthenticationService
         using (var hashAlgorithm = new HMACSHA1(securityToken))
 #endif
         {
+            var modifierBytes = modifier is not null ? _encoding.GetBytes(modifier) : null;
             for (var i = -2; i <= 2; i++)
             {
 #if NET6_0_OR_GREATER
-                var computedTotp = ComputeTotp(securityToken, (ulong)((long)currentTimeStep + i), modifier);
+                var computedTotp = ComputeTotp(securityToken, (ulong)((long)currentTimeStep + i), modifierBytes);
 #else
-                var computedTotp = ComputeTotp(hashAlgorithm, (ulong)((long)currentTimeStep + i), modifier);
+                var computedTotp = ComputeTotp(hashAlgorithm, (ulong)((long)currentTimeStep + i), modifierBytes);
 #endif
                 if (computedTotp == code)
                 {

+ 8 - 4
src/Identity/Extensions.Core/src/RoleManager.cs

@@ -425,19 +425,23 @@ public class RoleManager<TRole> : IDisposable where TRole : class
     /// <returns>A <see cref="IdentityResult"/> representing whether validation was successful.</returns>
     protected virtual async Task<IdentityResult> ValidateRoleAsync(TRole role)
     {
-        var errors = new List<IdentityError>();
+        List<IdentityError>? errors = null;
         foreach (var v in RoleValidators)
         {
             var result = await v.ValidateAsync(this, role).ConfigureAwait(false);
             if (!result.Succeeded)
             {
+                errors ??= new List<IdentityError>();
                 errors.AddRange(result.Errors);
             }
         }
-        if (errors.Count > 0)
+        if (errors?.Count > 0)
         {
-            Logger.LogWarning(LoggerEventIds.RoleValidationFailed, "Role {roleId} validation failed: {errors}.", await GetRoleIdAsync(role).ConfigureAwait(false), string.Join(";", errors.Select(e => e.Code)));
-            return IdentityResult.Failed(errors.ToArray());
+            if (Logger.IsEnabled(LogLevel.Warning))
+            {
+                Logger.LogWarning(LoggerEventIds.RoleValidationFailed, "Role {roleId} validation failed: {errors}.", await GetRoleIdAsync(role).ConfigureAwait(false), string.Join(";", errors.Select(e => e.Code)));
+            }
+            return IdentityResult.Failed(errors);
         }
         return IdentityResult.Success;
     }

+ 9 - 6
src/Identity/Extensions.Core/src/RoleValidator.cs

@@ -40,21 +40,21 @@ public class RoleValidator<TRole> : IRoleValidator<TRole> where TRole : class
         {
             throw new ArgumentNullException(nameof(role));
         }
-        var errors = new List<IdentityError>();
-        await ValidateRoleName(manager, role, errors).ConfigureAwait(false);
-        if (errors.Count > 0)
+        var errors = await ValidateRoleName(manager, role).ConfigureAwait(false);
+        if (errors?.Count > 0)
         {
-            return IdentityResult.Failed(errors.ToArray());
+            return IdentityResult.Failed(errors);
         }
         return IdentityResult.Success;
     }
 
-    private async Task ValidateRoleName(RoleManager<TRole> manager, TRole role,
-        ICollection<IdentityError> errors)
+    private async Task<List<IdentityError>?> ValidateRoleName(RoleManager<TRole> manager, TRole role)
     {
+        List<IdentityError>? errors = null;
         var roleName = await manager.GetRoleNameAsync(role).ConfigureAwait(false);
         if (string.IsNullOrWhiteSpace(roleName))
         {
+            errors ??= new List<IdentityError>();
             errors.Add(Describer.InvalidRoleName(roleName));
         }
         else
@@ -63,8 +63,11 @@ public class RoleValidator<TRole> : IRoleValidator<TRole> where TRole : class
             if (owner != null &&
                 !string.Equals(await manager.GetRoleIdAsync(owner).ConfigureAwait(false), await manager.GetRoleIdAsync(role).ConfigureAwait(false)))
             {
+                errors ??= new List<IdentityError>();
                 errors.Add(Describer.DuplicateRoleName(roleName));
             }
         }
+
+        return errors;
     }
 }

+ 28 - 9
src/Identity/Extensions.Core/src/UserManager.cs

@@ -2278,6 +2278,22 @@ public class UserManager<TUser> : IDisposable where TUser : class
     /// <returns></returns>
     protected virtual string CreateTwoFactorRecoveryCode()
     {
+#if NET6_0_OR_GREATER
+        return string.Create(11, 0, static (buffer, _) =>
+        {
+            buffer[10] = GetRandomRecoveryCodeChar();
+            buffer[9] = GetRandomRecoveryCodeChar();
+            buffer[8] = GetRandomRecoveryCodeChar();
+            buffer[7] = GetRandomRecoveryCodeChar();
+            buffer[6] = GetRandomRecoveryCodeChar();
+            buffer[5] = '-';
+            buffer[4] = GetRandomRecoveryCodeChar();
+            buffer[3] = GetRandomRecoveryCodeChar();
+            buffer[2] = GetRandomRecoveryCodeChar();
+            buffer[1] = GetRandomRecoveryCodeChar();
+            buffer[0] = GetRandomRecoveryCodeChar();
+        });
+#else
         var recoveryCode = new StringBuilder(11);
         recoveryCode.Append(GetRandomRecoveryCodeChar());
         recoveryCode.Append(GetRandomRecoveryCodeChar());
@@ -2291,6 +2307,7 @@ public class UserManager<TUser> : IDisposable where TUser : class
         recoveryCode.Append(GetRandomRecoveryCodeChar());
         recoveryCode.Append(GetRandomRecoveryCodeChar());
         return recoveryCode.ToString();
+#endif
     }
 
     // We don't want to use any confusing characters like 0/O 1/I/L/l
@@ -2487,13 +2504,13 @@ public class UserManager<TUser> : IDisposable where TUser : class
 
     private static string NewSecurityStamp()
     {
-        byte[] bytes = new byte[20];
 #if NETSTANDARD2_0 || NETFRAMEWORK
+        byte[] bytes = new byte[20];
         _rng.GetBytes(bytes);
+        return Base32.ToBase32(bytes);
 #else
-        RandomNumberGenerator.Fill(bytes);
+        return Base32.GenerateBase32();
 #endif
-        return Base32.ToBase32(bytes);
     }
 
     // IUserLoginStore methods
@@ -2550,19 +2567,20 @@ public class UserManager<TUser> : IDisposable where TUser : class
                 throw new InvalidOperationException(Resources.NullSecurityStamp);
             }
         }
-        var errors = new List<IdentityError>();
+        List<IdentityError>? errors = null;
         foreach (var v in UserValidators)
         {
             var result = await v.ValidateAsync(this, user).ConfigureAwait(false);
             if (!result.Succeeded)
             {
+                errors ??= new List<IdentityError>();
                 errors.AddRange(result.Errors);
             }
         }
-        if (errors.Count > 0)
+        if (errors?.Count > 0)
         {
             Logger.LogDebug(LoggerEventIds.UserValidationFailed, "User validation failed: {errors}.", string.Join(";", errors.Select(e => e.Code)));
-            return IdentityResult.Failed(errors.ToArray());
+            return IdentityResult.Failed(errors);
         }
         return IdentityResult.Success;
     }
@@ -2576,7 +2594,7 @@ public class UserManager<TUser> : IDisposable where TUser : class
     /// <returns>A <see cref="IdentityResult"/> representing whether validation was successful.</returns>
     protected async Task<IdentityResult> ValidatePasswordAsync(TUser user, string? password)
     {
-        var errors = new List<IdentityError>();
+        List<IdentityError>? errors = null;
         var isValid = true;
         foreach (var v in PasswordValidators)
         {
@@ -2585,6 +2603,7 @@ public class UserManager<TUser> : IDisposable where TUser : class
             {
                 if (result.Errors.Any())
                 {
+                    errors ??= new List<IdentityError>();
                     errors.AddRange(result.Errors);
                 }
 
@@ -2593,8 +2612,8 @@ public class UserManager<TUser> : IDisposable where TUser : class
         }
         if (!isValid)
         {
-            Logger.LogDebug(LoggerEventIds.PasswordValidationFailed, "User password validation failed: {errors}.", string.Join(";", errors.Select(e => e.Code)));
-            return IdentityResult.Failed(errors.ToArray());
+            Logger.LogDebug(LoggerEventIds.PasswordValidationFailed, "User password validation failed: {errors}.", string.Join(";", errors?.Select(e => e.Code) ?? Array.Empty<string>()));
+            return IdentityResult.Failed(errors);
         }
         return IdentityResult.Success;
     }

+ 17 - 8
src/Identity/Extensions.Core/src/UserValidator.cs

@@ -46,25 +46,27 @@ public class UserValidator<TUser> : IUserValidator<TUser> where TUser : class
         {
             throw new ArgumentNullException(nameof(user));
         }
-        var errors = new List<IdentityError>();
-        await ValidateUserName(manager, user, errors).ConfigureAwait(false);
+        var errors = await ValidateUserName(manager, user).ConfigureAwait(false);
         if (manager.Options.User.RequireUniqueEmail)
         {
-            await ValidateEmail(manager, user, errors).ConfigureAwait(false);
+            errors = await ValidateEmail(manager, user, errors).ConfigureAwait(false);
         }
-        return errors.Count > 0 ? IdentityResult.Failed(errors.ToArray()) : IdentityResult.Success;
+        return errors?.Count > 0 ? IdentityResult.Failed(errors) : IdentityResult.Success;
     }
 
-    private async Task ValidateUserName(UserManager<TUser> manager, TUser user, ICollection<IdentityError> errors)
+    private async Task<List<IdentityError>?> ValidateUserName(UserManager<TUser> manager, TUser user)
     {
+        List<IdentityError>? errors = null;
         var userName = await manager.GetUserNameAsync(user).ConfigureAwait(false);
         if (string.IsNullOrWhiteSpace(userName))
         {
+            errors ??= new List<IdentityError>();
             errors.Add(Describer.InvalidUserName(userName));
         }
         else if (!string.IsNullOrEmpty(manager.Options.User.AllowedUserNameCharacters) &&
             userName.Any(c => !manager.Options.User.AllowedUserNameCharacters.Contains(c)))
         {
+            errors ??= new List<IdentityError>();
             errors.Add(Describer.InvalidUserName(userName));
         }
         else
@@ -73,30 +75,37 @@ public class UserValidator<TUser> : IUserValidator<TUser> where TUser : class
             if (owner != null &&
                 !string.Equals(await manager.GetUserIdAsync(owner).ConfigureAwait(false), await manager.GetUserIdAsync(user).ConfigureAwait(false)))
             {
+                errors ??= new List<IdentityError>();
                 errors.Add(Describer.DuplicateUserName(userName));
             }
         }
+
+        return errors;
     }
 
     // make sure email is not empty, valid, and unique
-    private async Task ValidateEmail(UserManager<TUser> manager, TUser user, List<IdentityError> errors)
+    private async Task<List<IdentityError>?> ValidateEmail(UserManager<TUser> manager, TUser user, List<IdentityError>? errors)
     {
         var email = await manager.GetEmailAsync(user).ConfigureAwait(false);
         if (string.IsNullOrWhiteSpace(email))
         {
+            errors ??= new List<IdentityError>();
             errors.Add(Describer.InvalidEmail(email));
-            return;
+            return errors;
         }
         if (!new EmailAddressAttribute().IsValid(email))
         {
+            errors ??= new List<IdentityError>();
             errors.Add(Describer.InvalidEmail(email));
-            return;
+            return errors;
         }
         var owner = await manager.FindByEmailAsync(email).ConfigureAwait(false);
         if (owner != null &&
             !string.Equals(await manager.GetUserIdAsync(owner).ConfigureAwait(false), await manager.GetUserIdAsync(user).ConfigureAwait(false)))
         {
+            errors ??= new List<IdentityError>();
             errors.Add(Describer.DuplicateEmail(email));
         }
+        return errors;
     }
 }

+ 2 - 2
src/Identity/test/Identity.FunctionalTests/Pages/Account/Manage/EnableAuthenticator.cs

@@ -1,4 +1,4 @@
-// Licensed to the .NET Foundation under one or more agreements.
+// Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
 using System.Globalization;
@@ -48,7 +48,7 @@ internal class EnableAuthenticator : DefaultUIPage
         var keyBytes = Base32.FromBase32(key);
         var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
         var timestep = Convert.ToInt64(unixTimestamp / 30);
-        var topt = Rfc6238AuthenticationService.ComputeTotp(keyBytes, (ulong)timestep, modifier: null);
+        var topt = Rfc6238AuthenticationService.ComputeTotp(keyBytes, (ulong)timestep, modifierBytes: null);
         return topt.ToString("D6", CultureInfo.InvariantCulture);
     }
 }

+ 7 - 0
src/Identity/test/Identity.Test/Base32Test.cs

@@ -26,6 +26,13 @@ public class Base32Test
         Assert.Equal<byte>(data, Base32.FromBase32(Base32.ToBase32(data)));
     }
 
+    [Fact]
+    public void GenerateBase32IsValid()
+    {
+        var output = Base32.FromBase32(Base32.GenerateBase32());
+        Assert.Equal(20, output.Length);
+    }
+
     private static readonly RandomNumberGenerator _rng = RandomNumberGenerator.Create();
 
     private static byte[] GetRandomByteArray(int length)