Merge pull request #5612 from Bond-009/passwordhash

This commit is contained in:
Bond-009 2021-04-14 15:41:23 +02:00 committed by GitHub
commit 159431ad2a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 279 additions and 58 deletions

View File

@ -1,4 +1,5 @@
#pragma warning disable CS1591 #pragma warning disable CS1591
#nullable enable
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
@ -30,6 +31,16 @@ namespace MediaBrowser.Common.Cryptography
public PasswordHash(string id, byte[] hash, byte[] salt, Dictionary<string, string> parameters) public PasswordHash(string id, byte[] hash, byte[] salt, Dictionary<string, string> 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; Id = id;
_hash = hash; _hash = hash;
_salt = salt; _salt = salt;
@ -59,58 +70,109 @@ namespace MediaBrowser.Common.Cryptography
/// <value>Return the hashed password.</value> /// <value>Return the hashed password.</value>
public ReadOnlySpan<byte> Hash => _hash; public ReadOnlySpan<byte> Hash => _hash;
public static PasswordHash Parse(string hashString) public static PasswordHash Parse(ReadOnlySpan<char> hashString)
{ {
// The string should at least contain the hash function and the hash itself if (hashString.IsEmpty)
string[] splitted = hashString.Split('$');
if (splitted.Length < 3)
{ {
throw new ArgumentException("String doesn't contain enough segments", nameof(hashString)); throw new ArgumentException("String can't be empty", nameof(hashString));
} }
// Start at 1, the first index shouldn't contain any data if (hashString[0] != '$')
int index = 1; {
throw new FormatException("Hash string must start with a $");
}
// Name of the hash function // Ignore first $
string id = splitted[index++]; 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<byte>());
}
ReadOnlySpan<char> id = hashString[..nextSegment];
hashString = hashString[(nextSegment + 1)..];
Dictionary<string, string>? parameters = null;
nextSegment = hashString.IndexOf('$');
// Optional parameters // Optional parameters
Dictionary<string, string> parameters = new Dictionary<string, string>(); ReadOnlySpan<char> parametersSpan = nextSegment == -1 ? hashString : hashString[..nextSegment];
if (splitted[index].IndexOf('=', StringComparison.Ordinal) != -1) if (parametersSpan.Contains('='))
{ {
foreach (string paramset in splitted[index++].Split(',')) while (!parametersSpan.IsEmpty)
{ {
if (string.IsNullOrEmpty(paramset)) ReadOnlySpan<char> parameter;
int index = parametersSpan.IndexOf(',');
if (index == -1)
{ {
continue; parameter = parametersSpan;
parametersSpan = ReadOnlySpan<char>.Empty;
}
else
{
parameter = parametersSpan[..index];
parametersSpan = parametersSpan[(index + 1)..];
} }
string[] fields = paramset.Split('='); int splitIndex = parameter.IndexOf('=');
if (fields.Length != 2) if (splitIndex == -1 || splitIndex == 0 || splitIndex == parameter.Length - 1)
{ {
throw new InvalidDataException($"Malformed parameter in password hash string {paramset}"); throw new FormatException("Malformed parameter in password hash string");
} }
parameters.Add(fields[0], fields[1]); (parameters ??= new Dictionary<string, string>()).Add(
parameter[..splitIndex].ToString(),
parameter[(splitIndex + 1)..].ToString());
} }
if (nextSegment == -1)
{
// parameters can't be null here
return new PasswordHash(id.ToString(), Array.Empty<byte>(), Array.Empty<byte>(), parameters!);
}
hashString = hashString[(nextSegment + 1)..];
nextSegment = hashString.IndexOf('$');
}
if (nextSegment == 0)
{
throw new FormatException("Hash string contains an empty segment");
} }
byte[] hash; byte[] hash;
byte[] salt; byte[] salt;
// Check if the string also contains a salt if (nextSegment == -1)
if (splitted.Length - index == 2)
{ {
salt = Convert.FromHexString(splitted[index++]); salt = Array.Empty<byte>();
hash = Convert.FromHexString(splitted[index++]); hash = Convert.FromHexString(hashString);
} }
else else
{ {
salt = Array.Empty<byte>(); salt = Convert.FromHexString(hashString[..nextSegment]);
hash = Convert.FromHexString(splitted[index++]); 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, hash, salt, parameters); return new PasswordHash(id.ToString(), hash, salt, parameters ?? new Dictionary<string, string>());
} }
private void SerializeParameters(StringBuilder stringBuilder) private void SerializeParameters(StringBuilder stringBuilder)
@ -147,8 +209,13 @@ namespace MediaBrowser.Common.Cryptography
.Append(Convert.ToHexString(_salt)); .Append(Convert.ToHexString(_salt));
} }
return str.Append('$') if (_hash.Length != 0)
.Append(Convert.ToHexString(_hash)).ToString(); {
str.Append('$')
.Append(Convert.ToHexString(_hash));
}
return str.ToString();
} }
} }
} }

View File

@ -0,0 +1,185 @@
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<ArgumentNullException>(() => new PasswordHash(null!, Array.Empty<byte>()));
}
[Fact]
public static void Ctor_Empty_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() => new PasswordHash(string.Empty, Array.Empty<byte>()));
}
public static IEnumerable<object[]> Parse_Valid_TestData()
{
// Id
yield return new object[]
{
"$PBKDF2",
new PasswordHash("PBKDF2", Array.Empty<byte>())
};
// Id + parameter
yield return new object[]
{
"$PBKDF2$iterations=1000",
new PasswordHash(
"PBKDF2",
Array.Empty<byte>(),
Array.Empty<byte>(),
new Dictionary<string, string>()
{
{ "iterations", "1000" },
})
};
// Id + parameters
yield return new object[]
{
"$PBKDF2$iterations=1000,m=120",
new PasswordHash(
"PBKDF2",
Array.Empty<byte>(),
Array.Empty<byte>(),
new Dictionary<string, string>()
{
{ "iterations", "1000" },
{ "m", "120" }
})
};
// Id + hash
yield return new object[]
{
"$PBKDF2$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D",
new PasswordHash(
"PBKDF2",
Convert.FromHexString("62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"),
Array.Empty<byte>(),
new Dictionary<string, string>())
};
// Id + salt + hash
yield return new object[]
{
"$PBKDF2$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D",
new PasswordHash(
"PBKDF2",
Convert.FromHexString("62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"),
Convert.FromHexString("69F420"),
new Dictionary<string, string>())
};
// Id + parameter + hash
yield return new object[]
{
"$PBKDF2$iterations=1000$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D",
new PasswordHash(
"PBKDF2",
Convert.FromHexString("62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"),
Array.Empty<byte>(),
new Dictionary<string, string>()
{
{ "iterations", "1000" }
})
};
// Id + parameters + hash
yield return new object[]
{
"$PBKDF2$iterations=1000,m=120$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D",
new PasswordHash(
"PBKDF2",
Convert.FromHexString("62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"),
Array.Empty<byte>(),
new Dictionary<string, string>()
{
{ "iterations", "1000" },
{ "m", "120" }
})
};
// Id + parameters + salt + hash
yield return new object[]
{
"$PBKDF2$iterations=1000,m=120$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D",
new PasswordHash(
"PBKDF2",
Convert.FromHexString("62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"),
Convert.FromHexString("69F420"),
new Dictionary<string, string>()
{
{ "iterations", "1000" },
{ "m", "120" }
})
};
}
[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<ArgumentException>(() => PasswordHash.Parse(null));
}
[Fact]
public static void Parse_Empty_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() => 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=$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D$")] // Ends on $
[InlineData("$PBKDF2$iterations=$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D$")] // Extra segment
[InlineData("$PBKDF2$iterations=$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D$anotherone")] // Extra segment
[InlineData("$PBKDF2$iterations=$invalidstalt$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] // Invalid salt
[InlineData("$PBKDF2$iterations=$69F420$invalid hash")] // Invalid hash
[InlineData("$PBKDF2$69F420$")] // Empty hash
public static void Parse_InvalidFormat_ThrowsFormatException(string passwordHash)
{
Assert.Throws<FormatException>(() => PasswordHash.Parse(passwordHash));
}
}
}

View File

@ -1,31 +0,0 @@
using System;
using MediaBrowser.Common;
using MediaBrowser.Common.Cryptography;
using Xunit;
namespace Jellyfin.Common.Tests
{
public class PasswordHashTests
{
[Theory]
[InlineData(
"$PBKDF2$iterations=1000$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D",
"PBKDF2",
"",
"62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")]
public void ParseTest(string passwordHash, string id, string salt, string hash)
{
var pass = PasswordHash.Parse(passwordHash);
Assert.Equal(id, pass.Id);
Assert.Equal(salt, Convert.ToHexString(pass.Salt));
Assert.Equal(hash, Convert.ToHexString(pass.Hash));
}
[Theory]
[InlineData("$PBKDF2$iterations=1000$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")]
public void ToStringTest(string passwordHash)
{
Assert.Equal(passwordHash, PasswordHash.Parse(passwordHash).ToString());
}
}
}