From 3b492d4af8c432cc11b11e946b72aaf97cf63c95 Mon Sep 17 00:00:00 2001 From: Bond_009 Date: Fri, 8 Oct 2021 15:02:58 +0200 Subject: Use static crypto rng --- .../Cryptography/CryptographyProvider.cs | 54 +++------------------- 1 file changed, 6 insertions(+), 48 deletions(-) (limited to 'Emby.Server.Implementations/Cryptography') diff --git a/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs b/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs index 4a9b28085..673810c49 100644 --- a/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs +++ b/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs @@ -10,8 +10,12 @@ namespace Emby.Server.Implementations.Cryptography /// /// Class providing abstractions over cryptographic functions. /// - public class CryptographyProvider : ICryptoProvider, IDisposable + public class CryptographyProvider : ICryptoProvider { + // FIXME: When we get DotNet Standard 2.1 we need to revisit how we do the crypto + // Currently supported hash methods from https://docs.microsoft.com/en-us/dotnet/api/system.security.cryptography.cryptoconfig?view=netcore-2.1 + // there might be a better way to autogenerate this list as dotnet updates, but I couldn't find one + // Please note the default method of PBKDF2 is not included, it cannot be used to generate hashes cleanly as it is actually a pbkdf with sha1 private static readonly HashSet _supportedHashMethods = new HashSet() { "MD5", @@ -30,22 +34,6 @@ namespace Emby.Server.Implementations.Cryptography "System.Security.Cryptography.SHA512" }; - private RandomNumberGenerator _randomNumberGenerator; - - private bool _disposed; - - /// - /// Initializes a new instance of the class. - /// - public CryptographyProvider() - { - // FIXME: When we get DotNet Standard 2.1 we need to revisit how we do the crypto - // Currently supported hash methods from https://docs.microsoft.com/en-us/dotnet/api/system.security.cryptography.cryptoconfig?view=netcore-2.1 - // there might be a better way to autogenerate this list as dotnet updates, but I couldn't find one - // Please note the default method of PBKDF2 is not included, it cannot be used to generate hashes cleanly as it is actually a pbkdf with sha1 - _randomNumberGenerator = RandomNumberGenerator.Create(); - } - /// public string DefaultHashMethod => "PBKDF2"; @@ -101,36 +89,6 @@ namespace Emby.Server.Implementations.Cryptography /// public byte[] GenerateSalt(int length) - { - byte[] salt = new byte[length]; - _randomNumberGenerator.GetBytes(salt); - return salt; - } - - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Releases unmanaged and - optionally - managed resources. - /// - /// true to release both managed and unmanaged resources; false to release only unmanaged resources. - protected virtual void Dispose(bool disposing) - { - if (_disposed) - { - return; - } - - if (disposing) - { - _randomNumberGenerator.Dispose(); - } - - _disposed = true; - } + => RandomNumberGenerator.GetBytes(length); } } -- cgit v1.2.3 From 5265b3eee794762b4de39a68b5bfbf767faaac36 Mon Sep 17 00:00:00 2001 From: Bond_009 Date: Wed, 10 Nov 2021 22:34:54 +0100 Subject: Replace PBKDF2-SHA1 with PBKDF2-SHA512 This also migrates already created passwords on login Source for the number of iterations: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 --- .../Cryptography/CryptographyProvider.cs | 90 +++++---- .../LiveTv/Listings/SchedulesDirect.cs | 3 +- .../Users/DefaultAuthenticationProvider.cs | 35 ++-- .../Users/UserManager.cs | 8 +- MediaBrowser.Common/Cryptography/Constants.cs | 18 -- .../Cryptography/CryptoExtensions.cs | 35 ---- MediaBrowser.Common/Cryptography/PasswordHash.cs | 219 --------------------- MediaBrowser.Model/Cryptography/Constants.cs | 23 +++ MediaBrowser.Model/Cryptography/ICryptoProvider.cs | 13 +- MediaBrowser.Model/Cryptography/PasswordHash.cs | 219 +++++++++++++++++++++ .../Cryptography/PasswordHashTests.cs | 169 ---------------- .../Cryptography/PasswordHashTests.cs | 169 ++++++++++++++++ 12 files changed, 488 insertions(+), 513 deletions(-) delete mode 100644 MediaBrowser.Common/Cryptography/Constants.cs delete mode 100644 MediaBrowser.Common/Cryptography/CryptoExtensions.cs delete mode 100644 MediaBrowser.Common/Cryptography/PasswordHash.cs create mode 100644 MediaBrowser.Model/Cryptography/Constants.cs create mode 100644 MediaBrowser.Model/Cryptography/PasswordHash.cs delete mode 100644 tests/Jellyfin.Common.Tests/Cryptography/PasswordHashTests.cs create mode 100644 tests/Jellyfin.Model.Tests/Cryptography/PasswordHashTests.cs (limited to 'Emby.Server.Implementations/Cryptography') diff --git a/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs b/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs index 673810c49..e9c005cea 100644 --- a/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs +++ b/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Security.Cryptography; +using System.Text; using MediaBrowser.Common.Extensions; using MediaBrowser.Model.Cryptography; -using static MediaBrowser.Common.Cryptography.Constants; +using static MediaBrowser.Model.Cryptography.Constants; namespace Emby.Server.Implementations.Cryptography { @@ -12,10 +14,7 @@ namespace Emby.Server.Implementations.Cryptography /// public class CryptographyProvider : ICryptoProvider { - // FIXME: When we get DotNet Standard 2.1 we need to revisit how we do the crypto - // Currently supported hash methods from https://docs.microsoft.com/en-us/dotnet/api/system.security.cryptography.cryptoconfig?view=netcore-2.1 - // there might be a better way to autogenerate this list as dotnet updates, but I couldn't find one - // Please note the default method of PBKDF2 is not included, it cannot be used to generate hashes cleanly as it is actually a pbkdf with sha1 + // TODO: remove when not needed for backwards compat private static readonly HashSet _supportedHashMethods = new HashSet() { "MD5", @@ -35,60 +34,81 @@ namespace Emby.Server.Implementations.Cryptography }; /// - public string DefaultHashMethod => "PBKDF2"; + public string DefaultHashMethod => "PBKDF2-SHA512"; /// - public IEnumerable GetSupportedHashMethods() - => _supportedHashMethods; - - private byte[] PBKDF2(string method, byte[] bytes, byte[] salt, int iterations) + public PasswordHash CreatePasswordHash(ReadOnlySpan password) { - // downgrading for now as we need this library to be dotnetstandard compliant - // with this downgrade we'll add a check to make sure we're on the downgrade method at the moment - if (method != DefaultHashMethod) - { - throw new CryptographicException($"Cannot currently use PBKDF2 with requested hash method: {method}"); - } - - using var r = new Rfc2898DeriveBytes(bytes, salt, iterations); - return r.GetBytes(32); + byte[] salt = GenerateSalt(); + return new PasswordHash( + DefaultHashMethod, + Rfc2898DeriveBytes.Pbkdf2( + password, + salt, + DefaultIterations, + HashAlgorithmName.SHA512, + DefaultOutputLength), + salt, + new Dictionary + { + { "iterations", DefaultIterations.ToString(CultureInfo.InvariantCulture) } + }); } /// - public byte[] ComputeHash(string hashMethod, byte[] bytes, byte[] salt) + public bool Verify(PasswordHash hash, ReadOnlySpan password) { - if (hashMethod == DefaultHashMethod) + if (string.Equals(hash.Id, "PBKDF2", StringComparison.Ordinal)) { - return PBKDF2(hashMethod, bytes, salt, DefaultIterations); + return hash.Hash.SequenceEqual( + Rfc2898DeriveBytes.Pbkdf2( + password, + hash.Salt, + int.Parse(hash.Parameters["iterations"], CultureInfo.InvariantCulture), + HashAlgorithmName.SHA1, + 32)); } - if (!_supportedHashMethods.Contains(hashMethod)) + if (string.Equals(hash.Id, "PBKDF2-SHA512", StringComparison.Ordinal)) { - throw new CryptographicException($"Requested hash method is not supported: {hashMethod}"); + return hash.Hash.SequenceEqual( + Rfc2898DeriveBytes.Pbkdf2( + password, + hash.Salt, + int.Parse(hash.Parameters["iterations"], CultureInfo.InvariantCulture), + HashAlgorithmName.SHA512, + DefaultOutputLength)); } - using var h = HashAlgorithm.Create(hashMethod) ?? throw new ResourceNotFoundException($"Unknown hash method: {hashMethod}."); - if (salt.Length == 0) + if (!_supportedHashMethods.Contains(hash.Id)) { - return h.ComputeHash(bytes); + throw new CryptographicException($"Requested hash method is not supported: {hash.Id}"); } - byte[] salted = new byte[bytes.Length + salt.Length]; + using var h = HashAlgorithm.Create(hash.Id) ?? throw new ResourceNotFoundException($"Unknown hash method: {hash.Id}."); + var bytes = Encoding.UTF8.GetBytes(password.ToArray()); + if (hash.Salt.Length == 0) + { + return hash.Hash.SequenceEqual(h.ComputeHash(bytes)); + } + + byte[] salted = new byte[bytes.Length + hash.Salt.Length]; Array.Copy(bytes, salted, bytes.Length); - Array.Copy(salt, 0, salted, bytes.Length, salt.Length); - return h.ComputeHash(salted); + hash.Salt.CopyTo(salted.AsSpan(bytes.Length)); + return hash.Hash.SequenceEqual(h.ComputeHash(salted)); } - /// - public byte[] ComputeHashWithDefaultMethod(byte[] bytes, byte[] salt) - => PBKDF2(DefaultHashMethod, bytes, salt, DefaultIterations); - /// public byte[] GenerateSalt() => GenerateSalt(DefaultSaltLength); /// public byte[] GenerateSalt(int length) - => RandomNumberGenerator.GetBytes(length); + { + var salt = new byte[length]; + using var rng = RandomNumberGenerator.Create(); + rng.GetNonZeroBytes(salt); + return salt; + } } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs index 1f963e4a2..615539db3 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs @@ -11,6 +11,7 @@ using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Net.Mime; +using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Threading; @@ -648,7 +649,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings CancellationToken cancellationToken) { using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/token"); - var hashedPasswordBytes = _cryptoProvider.ComputeHash("SHA1", Encoding.ASCII.GetBytes(password), Array.Empty()); + var hashedPasswordBytes = SHA1.HashData(Encoding.ASCII.GetBytes(password)); // TODO: remove ToLower when Convert.ToHexString supports lowercase // Schedules Direct requires the hex to be lowercase string hashedPassword = Convert.ToHexString(hashedPasswordBytes).ToLowerInvariant(); diff --git a/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs index 6a78e7ee6..7480a05c2 100644 --- a/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs +++ b/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs @@ -1,9 +1,6 @@ using System; -using System.Linq; -using System.Text; using System.Threading.Tasks; using Jellyfin.Data.Entities; -using MediaBrowser.Common.Cryptography; using MediaBrowser.Controller.Authentication; using MediaBrowser.Model.Cryptography; @@ -61,35 +58,25 @@ namespace Jellyfin.Server.Implementations.Users } // Handle the case when the stored password is null, but the user tried to login with a password - if (resolvedUser.Password != null) + if (resolvedUser.Password == null) { - byte[] passwordBytes = Encoding.UTF8.GetBytes(password); - - PasswordHash readyHash = PasswordHash.Parse(resolvedUser.Password); - if (_cryptographyProvider.GetSupportedHashMethods().Contains(readyHash.Id) - || _cryptographyProvider.DefaultHashMethod == readyHash.Id) - { - byte[] calculatedHash = _cryptographyProvider.ComputeHash( - readyHash.Id, - passwordBytes, - readyHash.Salt.ToArray()); - - if (readyHash.Hash.SequenceEqual(calculatedHash)) - { - success = true; - } - } - else - { - throw new AuthenticationException($"Requested crypto method not available in provider: {readyHash.Id}"); - } + throw new AuthenticationException("Invalid username or password"); } + PasswordHash readyHash = PasswordHash.Parse(resolvedUser.Password); + success = _cryptographyProvider.Verify(readyHash, password); + if (!success) { throw new AuthenticationException("Invalid username or password"); } + // Migrate old hashes to the new default + if (!string.Equals(readyHash.Id, _cryptographyProvider.DefaultHashMethod, StringComparison.Ordinal)) + { + ChangePassword(resolvedUser, password); + } + return Task.FromResult(new ProviderAuthenticationResult { Username = username diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 8ca6e8d21..3d0a51ff6 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -5,7 +5,6 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.Linq; -using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; using Jellyfin.Data.Entities; @@ -13,7 +12,6 @@ using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using Jellyfin.Data.Events.Users; using MediaBrowser.Common; -using MediaBrowser.Common.Cryptography; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Authentication; @@ -818,11 +816,7 @@ namespace Jellyfin.Server.Implementations.Users { // Check easy password var passwordHash = PasswordHash.Parse(user.EasyPassword); - var hash = _cryptoProvider.ComputeHash( - passwordHash.Id, - Encoding.UTF8.GetBytes(password), - passwordHash.Salt.ToArray()); - success = passwordHash.Hash.SequenceEqual(hash); + success = _cryptoProvider.Verify(passwordHash, password); } return (authenticationProvider, username, success); diff --git a/MediaBrowser.Common/Cryptography/Constants.cs b/MediaBrowser.Common/Cryptography/Constants.cs deleted file mode 100644 index 354114232..000000000 --- a/MediaBrowser.Common/Cryptography/Constants.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace MediaBrowser.Common.Cryptography -{ - /// - /// Class containing global constants for Jellyfin Cryptography. - /// - public static class Constants - { - /// - /// The default length for new salts. - /// - public const int DefaultSaltLength = 64; - - /// - /// The default amount of iterations for hashing passwords. - /// - public const int DefaultIterations = 1000; - } -} diff --git a/MediaBrowser.Common/Cryptography/CryptoExtensions.cs b/MediaBrowser.Common/Cryptography/CryptoExtensions.cs deleted file mode 100644 index 157b0ed10..000000000 --- a/MediaBrowser.Common/Cryptography/CryptoExtensions.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Collections.Generic; -using System.Globalization; -using System.Text; -using MediaBrowser.Model.Cryptography; -using static MediaBrowser.Common.Cryptography.Constants; - -namespace MediaBrowser.Common.Cryptography -{ - /// - /// Class containing extension methods for working with Jellyfin cryptography objects. - /// - public static class CryptoExtensions - { - /// - /// Creates a new instance. - /// - /// The instance used. - /// The password that will be hashed. - /// A instance with the hash method, hash, salt and number of iterations. - public static PasswordHash CreatePasswordHash(this ICryptoProvider cryptoProvider, string password) - { - byte[] salt = cryptoProvider.GenerateSalt(); - return new PasswordHash( - cryptoProvider.DefaultHashMethod, - cryptoProvider.ComputeHashWithDefaultMethod( - Encoding.UTF8.GetBytes(password), - salt), - salt, - new Dictionary - { - { "iterations", DefaultIterations.ToString(CultureInfo.InvariantCulture) } - }); - } - } -} diff --git a/MediaBrowser.Common/Cryptography/PasswordHash.cs b/MediaBrowser.Common/Cryptography/PasswordHash.cs deleted file mode 100644 index 0e2065302..000000000 --- a/MediaBrowser.Common/Cryptography/PasswordHash.cs +++ /dev/null @@ -1,219 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Text; - -namespace MediaBrowser.Common.Cryptography -{ - // Defined from this hash storage spec - // https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md - // $[$=(,=)*][$[$]] - // with one slight amendment to ease the transition, we're writing out the bytes in hex - // rather than making them a BASE64 string with stripped padding - public class PasswordHash - { - private readonly Dictionary _parameters; - private readonly byte[] _salt; - private readonly byte[] _hash; - - public PasswordHash(string id, byte[] hash) - : this(id, hash, Array.Empty()) - { - } - - public PasswordHash(string id, byte[] hash, byte[] salt) - : this(id, hash, salt, new Dictionary()) - { - } - - public PasswordHash(string id, byte[] hash, byte[] salt, Dictionary parameters) - { - if (id == null) - { - throw new ArgumentNullException(nameof(id)); - } - - if (id.Length == 0) - { - throw new ArgumentException("String can't be empty", nameof(id)); - } - - Id = id; - _hash = hash; - _salt = salt; - _parameters = parameters; - } - - /// - /// Gets the symbolic name for the function used. - /// - /// Returns the symbolic name for the function used. - public string Id { get; } - - /// - /// Gets the additional parameters used by the hash function. - /// - public IReadOnlyDictionary Parameters => _parameters; - - /// - /// Gets the salt used for hashing the password. - /// - /// Returns the salt used for hashing the password. - public ReadOnlySpan Salt => _salt; - - /// - /// Gets the hashed password. - /// - /// Return the hashed password. - public ReadOnlySpan Hash => _hash; - - public static PasswordHash Parse(ReadOnlySpan hashString) - { - if (hashString.IsEmpty) - { - throw new ArgumentException("String can't be empty", nameof(hashString)); - } - - if (hashString[0] != '$') - { - throw new FormatException("Hash string must start with a $"); - } - - // Ignore first $ - hashString = hashString[1..]; - - int nextSegment = hashString.IndexOf('$'); - if (hashString.IsEmpty || nextSegment == 0) - { - throw new FormatException("Hash string must contain a valid id"); - } - else if (nextSegment == -1) - { - return new PasswordHash(hashString.ToString(), Array.Empty()); - } - - ReadOnlySpan id = hashString[..nextSegment]; - hashString = hashString[(nextSegment + 1)..]; - Dictionary? parameters = null; - - nextSegment = hashString.IndexOf('$'); - - // Optional parameters - ReadOnlySpan parametersSpan = nextSegment == -1 ? hashString : hashString[..nextSegment]; - if (parametersSpan.Contains('=')) - { - while (!parametersSpan.IsEmpty) - { - ReadOnlySpan parameter; - int index = parametersSpan.IndexOf(','); - if (index == -1) - { - parameter = parametersSpan; - parametersSpan = ReadOnlySpan.Empty; - } - else - { - parameter = parametersSpan[..index]; - parametersSpan = parametersSpan[(index + 1)..]; - } - - int splitIndex = parameter.IndexOf('='); - if (splitIndex == -1 || splitIndex == 0 || splitIndex == parameter.Length - 1) - { - throw new FormatException("Malformed parameter in password hash string"); - } - - (parameters ??= new Dictionary()).Add( - parameter[..splitIndex].ToString(), - parameter[(splitIndex + 1)..].ToString()); - } - - if (nextSegment == -1) - { - // parameters can't be null here - return new PasswordHash(id.ToString(), Array.Empty(), Array.Empty(), parameters!); - } - - hashString = hashString[(nextSegment + 1)..]; - nextSegment = hashString.IndexOf('$'); - } - - if (nextSegment == 0) - { - throw new FormatException("Hash string contains an empty segment"); - } - - byte[] hash; - byte[] salt; - - if (nextSegment == -1) - { - salt = Array.Empty(); - hash = Convert.FromHexString(hashString); - } - else - { - salt = Convert.FromHexString(hashString[..nextSegment]); - hashString = hashString[(nextSegment + 1)..]; - nextSegment = hashString.IndexOf('$'); - if (nextSegment != -1) - { - throw new FormatException("Hash string contains too many segments"); - } - - if (hashString.IsEmpty) - { - throw new FormatException("Hash segment is empty"); - } - - hash = Convert.FromHexString(hashString); - } - - return new PasswordHash(id.ToString(), hash, salt, parameters ?? new Dictionary()); - } - - private void SerializeParameters(StringBuilder stringBuilder) - { - if (_parameters.Count == 0) - { - return; - } - - stringBuilder.Append('$'); - foreach (var pair in _parameters) - { - stringBuilder.Append(pair.Key) - .Append('=') - .Append(pair.Value) - .Append(','); - } - - // Remove last ',' - stringBuilder.Length -= 1; - } - - /// - public override string ToString() - { - var str = new StringBuilder() - .Append('$') - .Append(Id); - SerializeParameters(str); - - if (_salt.Length != 0) - { - str.Append('$') - .Append(Convert.ToHexString(_salt)); - } - - if (_hash.Length != 0) - { - str.Append('$') - .Append(Convert.ToHexString(_hash)); - } - - return str.ToString(); - } - } -} diff --git a/MediaBrowser.Model/Cryptography/Constants.cs b/MediaBrowser.Model/Cryptography/Constants.cs new file mode 100644 index 000000000..f2ebb5d3d --- /dev/null +++ b/MediaBrowser.Model/Cryptography/Constants.cs @@ -0,0 +1,23 @@ +namespace MediaBrowser.Model.Cryptography +{ + /// + /// Class containing global constants for Jellyfin Cryptography. + /// + public static class Constants + { + /// + /// The default length for new salts. + /// + public const int DefaultSaltLength = 128 / 8; + + /// + /// The default output length. + /// + public const int DefaultOutputLength = 512 / 8; + + /// + /// The default amount of iterations for hashing passwords. + /// + public const int DefaultIterations = 120000; + } +} diff --git a/MediaBrowser.Model/Cryptography/ICryptoProvider.cs b/MediaBrowser.Model/Cryptography/ICryptoProvider.cs index d8b7d848a..6c521578c 100644 --- a/MediaBrowser.Model/Cryptography/ICryptoProvider.cs +++ b/MediaBrowser.Model/Cryptography/ICryptoProvider.cs @@ -1,6 +1,6 @@ #pragma warning disable CS1591 -using System.Collections.Generic; +using System; namespace MediaBrowser.Model.Cryptography { @@ -8,11 +8,14 @@ namespace MediaBrowser.Model.Cryptography { string DefaultHashMethod { get; } - IEnumerable GetSupportedHashMethods(); + /// + /// Creates a new instance. + /// + /// The password that will be hashed. + /// A instance with the hash method, hash, salt and number of iterations. + PasswordHash CreatePasswordHash(ReadOnlySpan password); - byte[] ComputeHash(string hashMethod, byte[] bytes, byte[] salt); - - byte[] ComputeHashWithDefaultMethod(byte[] bytes, byte[] salt); + bool Verify(PasswordHash hash, ReadOnlySpan password); byte[] GenerateSalt(); diff --git a/MediaBrowser.Model/Cryptography/PasswordHash.cs b/MediaBrowser.Model/Cryptography/PasswordHash.cs new file mode 100644 index 000000000..eec541041 --- /dev/null +++ b/MediaBrowser.Model/Cryptography/PasswordHash.cs @@ -0,0 +1,219 @@ +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using System.Text; + +namespace MediaBrowser.Model.Cryptography +{ + // Defined from this hash storage spec + // https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md + // $[$=(,=)*][$[$]] + // with one slight amendment to ease the transition, we're writing out the bytes in hex + // rather than making them a BASE64 string with stripped padding + public class PasswordHash + { + private readonly Dictionary _parameters; + private readonly byte[] _salt; + private readonly byte[] _hash; + + public PasswordHash(string id, byte[] hash) + : this(id, hash, Array.Empty()) + { + } + + public PasswordHash(string id, byte[] hash, byte[] salt) + : this(id, hash, salt, new Dictionary()) + { + } + + public PasswordHash(string id, byte[] hash, byte[] salt, Dictionary parameters) + { + if (id == null) + { + throw new ArgumentNullException(nameof(id)); + } + + if (id.Length == 0) + { + throw new ArgumentException("String can't be empty", nameof(id)); + } + + Id = id; + _hash = hash; + _salt = salt; + _parameters = parameters; + } + + /// + /// Gets the symbolic name for the function used. + /// + /// Returns the symbolic name for the function used. + public string Id { get; } + + /// + /// Gets the additional parameters used by the hash function. + /// + public IReadOnlyDictionary Parameters => _parameters; + + /// + /// Gets the salt used for hashing the password. + /// + /// Returns the salt used for hashing the password. + public ReadOnlySpan Salt => _salt; + + /// + /// Gets the hashed password. + /// + /// Return the hashed password. + public ReadOnlySpan Hash => _hash; + + public static PasswordHash Parse(ReadOnlySpan hashString) + { + if (hashString.IsEmpty) + { + throw new ArgumentException("String can't be empty", nameof(hashString)); + } + + if (hashString[0] != '$') + { + throw new FormatException("Hash string must start with a $"); + } + + // Ignore first $ + hashString = hashString[1..]; + + int nextSegment = hashString.IndexOf('$'); + if (hashString.IsEmpty || nextSegment == 0) + { + throw new FormatException("Hash string must contain a valid id"); + } + else if (nextSegment == -1) + { + return new PasswordHash(hashString.ToString(), Array.Empty()); + } + + ReadOnlySpan id = hashString[..nextSegment]; + hashString = hashString[(nextSegment + 1)..]; + Dictionary? parameters = null; + + nextSegment = hashString.IndexOf('$'); + + // Optional parameters + ReadOnlySpan parametersSpan = nextSegment == -1 ? hashString : hashString[..nextSegment]; + if (parametersSpan.Contains('=')) + { + while (!parametersSpan.IsEmpty) + { + ReadOnlySpan parameter; + int index = parametersSpan.IndexOf(','); + if (index == -1) + { + parameter = parametersSpan; + parametersSpan = ReadOnlySpan.Empty; + } + else + { + parameter = parametersSpan[..index]; + parametersSpan = parametersSpan[(index + 1)..]; + } + + int splitIndex = parameter.IndexOf('='); + if (splitIndex == -1 || splitIndex == 0 || splitIndex == parameter.Length - 1) + { + throw new FormatException("Malformed parameter in password hash string"); + } + + (parameters ??= new Dictionary()).Add( + parameter[..splitIndex].ToString(), + parameter[(splitIndex + 1)..].ToString()); + } + + if (nextSegment == -1) + { + // parameters can't be null here + return new PasswordHash(id.ToString(), Array.Empty(), Array.Empty(), parameters!); + } + + hashString = hashString[(nextSegment + 1)..]; + nextSegment = hashString.IndexOf('$'); + } + + if (nextSegment == 0) + { + throw new FormatException("Hash string contains an empty segment"); + } + + byte[] hash; + byte[] salt; + + if (nextSegment == -1) + { + salt = Array.Empty(); + hash = Convert.FromHexString(hashString); + } + else + { + salt = Convert.FromHexString(hashString[..nextSegment]); + hashString = hashString[(nextSegment + 1)..]; + nextSegment = hashString.IndexOf('$'); + if (nextSegment != -1) + { + throw new FormatException("Hash string contains too many segments"); + } + + if (hashString.IsEmpty) + { + throw new FormatException("Hash segment is empty"); + } + + hash = Convert.FromHexString(hashString); + } + + return new PasswordHash(id.ToString(), hash, salt, parameters ?? new Dictionary()); + } + + private void SerializeParameters(StringBuilder stringBuilder) + { + if (_parameters.Count == 0) + { + return; + } + + stringBuilder.Append('$'); + foreach (var pair in _parameters) + { + stringBuilder.Append(pair.Key) + .Append('=') + .Append(pair.Value) + .Append(','); + } + + // Remove last ',' + stringBuilder.Length -= 1; + } + + /// + public override string ToString() + { + var str = new StringBuilder() + .Append('$') + .Append(Id); + SerializeParameters(str); + + if (_salt.Length != 0) + { + str.Append('$') + .Append(Convert.ToHexString(_salt)); + } + + if (_hash.Length != 0) + { + str.Append('$') + .Append(Convert.ToHexString(_hash)); + } + + return str.ToString(); + } + } +} diff --git a/tests/Jellyfin.Common.Tests/Cryptography/PasswordHashTests.cs b/tests/Jellyfin.Common.Tests/Cryptography/PasswordHashTests.cs deleted file mode 100644 index bfece97b6..000000000 --- a/tests/Jellyfin.Common.Tests/Cryptography/PasswordHashTests.cs +++ /dev/null @@ -1,169 +0,0 @@ -using System; -using System.Collections.Generic; -using MediaBrowser.Common.Cryptography; -using Xunit; - -namespace Jellyfin.Common.Tests.Cryptography -{ - public static class PasswordHashTests - { - [Fact] - public static void Ctor_Null_ThrowsArgumentNullException() - { - Assert.Throws(() => new PasswordHash(null!, Array.Empty())); - } - - [Fact] - public static void Ctor_Empty_ThrowsArgumentException() - { - Assert.Throws(() => new PasswordHash(string.Empty, Array.Empty())); - } - - public static TheoryData Parse_Valid_TestData() - { - var data = new TheoryData(); - // Id - data.Add( - "$PBKDF2", - new PasswordHash("PBKDF2", Array.Empty())); - - // Id + parameter - data.Add( - "$PBKDF2$iterations=1000", - new PasswordHash( - "PBKDF2", - Array.Empty(), - Array.Empty(), - new Dictionary() - { - { "iterations", "1000" }, - })); - - // Id + parameters - data.Add( - "$PBKDF2$iterations=1000,m=120", - new PasswordHash( - "PBKDF2", - Array.Empty(), - Array.Empty(), - new Dictionary() - { - { "iterations", "1000" }, - { "m", "120" } - })); - - // Id + hash - data.Add( - "$PBKDF2$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D", - new PasswordHash( - "PBKDF2", - Convert.FromHexString("62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"), - Array.Empty(), - new Dictionary())); - - // Id + salt + hash - data.Add( - "$PBKDF2$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D", - new PasswordHash( - "PBKDF2", - Convert.FromHexString("62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"), - Convert.FromHexString("69F420"), - new Dictionary())); - - // Id + parameter + hash - data.Add( - "$PBKDF2$iterations=1000$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D", - new PasswordHash( - "PBKDF2", - Convert.FromHexString("62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"), - Array.Empty(), - new Dictionary() - { - { "iterations", "1000" } - })); - // Id + parameters + hash - data.Add( - "$PBKDF2$iterations=1000,m=120$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D", - new PasswordHash( - "PBKDF2", - Convert.FromHexString("62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"), - Array.Empty(), - new Dictionary() - { - { "iterations", "1000" }, - { "m", "120" } - })); - // Id + parameters + salt + hash - data.Add( - "$PBKDF2$iterations=1000,m=120$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D", - new PasswordHash( - "PBKDF2", - Convert.FromHexString("62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"), - Convert.FromHexString("69F420"), - new Dictionary() - { - { "iterations", "1000" }, - { "m", "120" } - })); - return data; - } - - [Theory] - [MemberData(nameof(Parse_Valid_TestData))] - public static void Parse_Valid_Success(string passwordHashString, PasswordHash expected) - { - var passwordHash = PasswordHash.Parse(passwordHashString); - Assert.Equal(expected.Id, passwordHash.Id); - Assert.Equal(expected.Parameters, passwordHash.Parameters); - Assert.Equal(expected.Salt.ToArray(), passwordHash.Salt.ToArray()); - Assert.Equal(expected.Hash.ToArray(), passwordHash.Hash.ToArray()); - Assert.Equal(expected.ToString(), passwordHash.ToString()); - } - - [Theory] - [InlineData("$PBKDF2")] - [InlineData("$PBKDF2$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] - [InlineData("$PBKDF2$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] - [InlineData("$PBKDF2$iterations=1000$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] - [InlineData("$PBKDF2$iterations=1000,m=120$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] - [InlineData("$PBKDF2$iterations=1000,m=120$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] - [InlineData("$PBKDF2$iterations=1000,m=120")] - public static void ToString_Roundtrip_Success(string passwordHash) - { - Assert.Equal(passwordHash, PasswordHash.Parse(passwordHash).ToString()); - } - - [Fact] - public static void Parse_Null_ThrowsArgumentException() - { - Assert.Throws(() => PasswordHash.Parse(null)); - } - - [Fact] - public static void Parse_Empty_ThrowsArgumentException() - { - Assert.Throws(() => PasswordHash.Parse(string.Empty)); - } - - [Theory] - [InlineData("$")] // No id - [InlineData("$$")] // Empty segments - [InlineData("PBKDF2$")] // Doesn't start with $ - [InlineData("$PBKDF2$$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] // Empty segment - [InlineData("$PBKDF2$iterations=1000$$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] // Empty salt segment - [InlineData("$PBKDF2$iterations=1000$69F420$")] // Empty hash segment - [InlineData("$PBKDF2$=$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] // Invalid parmeter - [InlineData("$PBKDF2$=1000$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] // Invalid parmeter - [InlineData("$PBKDF2$iterations=$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] // Invalid parmeter - [InlineData("$PBKDF2$iterations=1000$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D$")] // Ends on $ - [InlineData("$PBKDF2$iterations=1000$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D$")] // Extra segment - [InlineData("$PBKDF2$iterations=1000$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D$anotherone")] // Extra segment - [InlineData("$PBKDF2$iterations=1000$invalidstalt$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] // Invalid salt - [InlineData("$PBKDF2$iterations=1000$69F420$invalid hash")] // Invalid hash - [InlineData("$PBKDF2$69F420$")] // Empty hash - public static void Parse_InvalidFormat_ThrowsFormatException(string passwordHash) - { - Assert.Throws(() => PasswordHash.Parse(passwordHash)); - } - } -} diff --git a/tests/Jellyfin.Model.Tests/Cryptography/PasswordHashTests.cs b/tests/Jellyfin.Model.Tests/Cryptography/PasswordHashTests.cs new file mode 100644 index 000000000..6948280a3 --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Cryptography/PasswordHashTests.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Model.Cryptography; +using Xunit; + +namespace Jellyfin.Model.Tests.Cryptography +{ + public static class PasswordHashTests + { + [Fact] + public static void Ctor_Null_ThrowsArgumentNullException() + { + Assert.Throws(() => new PasswordHash(null!, Array.Empty())); + } + + [Fact] + public static void Ctor_Empty_ThrowsArgumentException() + { + Assert.Throws(() => new PasswordHash(string.Empty, Array.Empty())); + } + + public static TheoryData Parse_Valid_TestData() + { + var data = new TheoryData(); + // Id + data.Add( + "$PBKDF2", + new PasswordHash("PBKDF2", Array.Empty())); + + // Id + parameter + data.Add( + "$PBKDF2$iterations=1000", + new PasswordHash( + "PBKDF2", + Array.Empty(), + Array.Empty(), + new Dictionary() + { + { "iterations", "1000" }, + })); + + // Id + parameters + data.Add( + "$PBKDF2$iterations=1000,m=120", + new PasswordHash( + "PBKDF2", + Array.Empty(), + Array.Empty(), + new Dictionary() + { + { "iterations", "1000" }, + { "m", "120" } + })); + + // Id + hash + data.Add( + "$PBKDF2$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D", + new PasswordHash( + "PBKDF2", + Convert.FromHexString("62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"), + Array.Empty(), + new Dictionary())); + + // Id + salt + hash + data.Add( + "$PBKDF2$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D", + new PasswordHash( + "PBKDF2", + Convert.FromHexString("62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"), + Convert.FromHexString("69F420"), + new Dictionary())); + + // Id + parameter + hash + data.Add( + "$PBKDF2$iterations=1000$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D", + new PasswordHash( + "PBKDF2", + Convert.FromHexString("62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"), + Array.Empty(), + new Dictionary() + { + { "iterations", "1000" } + })); + // Id + parameters + hash + data.Add( + "$PBKDF2$iterations=1000,m=120$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D", + new PasswordHash( + "PBKDF2", + Convert.FromHexString("62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"), + Array.Empty(), + new Dictionary() + { + { "iterations", "1000" }, + { "m", "120" } + })); + // Id + parameters + salt + hash + data.Add( + "$PBKDF2$iterations=1000,m=120$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D", + new PasswordHash( + "PBKDF2", + Convert.FromHexString("62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"), + Convert.FromHexString("69F420"), + new Dictionary() + { + { "iterations", "1000" }, + { "m", "120" } + })); + return data; + } + + [Theory] + [MemberData(nameof(Parse_Valid_TestData))] + public static void Parse_Valid_Success(string passwordHashString, PasswordHash expected) + { + var passwordHash = PasswordHash.Parse(passwordHashString); + Assert.Equal(expected.Id, passwordHash.Id); + Assert.Equal(expected.Parameters, passwordHash.Parameters); + Assert.Equal(expected.Salt.ToArray(), passwordHash.Salt.ToArray()); + Assert.Equal(expected.Hash.ToArray(), passwordHash.Hash.ToArray()); + Assert.Equal(expected.ToString(), passwordHash.ToString()); + } + + [Theory] + [InlineData("$PBKDF2")] + [InlineData("$PBKDF2$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] + [InlineData("$PBKDF2$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] + [InlineData("$PBKDF2$iterations=1000$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] + [InlineData("$PBKDF2$iterations=1000,m=120$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] + [InlineData("$PBKDF2$iterations=1000,m=120$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] + [InlineData("$PBKDF2$iterations=1000,m=120")] + public static void ToString_Roundtrip_Success(string passwordHash) + { + Assert.Equal(passwordHash, PasswordHash.Parse(passwordHash).ToString()); + } + + [Fact] + public static void Parse_Null_ThrowsArgumentException() + { + Assert.Throws(() => PasswordHash.Parse(null)); + } + + [Fact] + public static void Parse_Empty_ThrowsArgumentException() + { + Assert.Throws(() => PasswordHash.Parse(string.Empty)); + } + + [Theory] + [InlineData("$")] // No id + [InlineData("$$")] // Empty segments + [InlineData("PBKDF2$")] // Doesn't start with $ + [InlineData("$PBKDF2$$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] // Empty segment + [InlineData("$PBKDF2$iterations=1000$$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] // Empty salt segment + [InlineData("$PBKDF2$iterations=1000$69F420$")] // Empty hash segment + [InlineData("$PBKDF2$=$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] // Invalid parmeter + [InlineData("$PBKDF2$=1000$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] // Invalid parmeter + [InlineData("$PBKDF2$iterations=$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] // Invalid parmeter + [InlineData("$PBKDF2$iterations=1000$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D$")] // Ends on $ + [InlineData("$PBKDF2$iterations=1000$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D$")] // Extra segment + [InlineData("$PBKDF2$iterations=1000$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D$anotherone")] // Extra segment + [InlineData("$PBKDF2$iterations=1000$invalidstalt$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] // Invalid salt + [InlineData("$PBKDF2$iterations=1000$69F420$invalid hash")] // Invalid hash + [InlineData("$PBKDF2$69F420$")] // Empty hash + public static void Parse_InvalidFormat_ThrowsFormatException(string passwordHash) + { + Assert.Throws(() => PasswordHash.Parse(passwordHash)); + } + } +} -- cgit v1.2.3