diff --git a/src/Core/Enums/EncryptionType.cs b/src/Core/Enums/EncryptionType.cs index a37110911f..776ca99a93 100644 --- a/src/Core/Enums/EncryptionType.cs +++ b/src/Core/Enums/EncryptionType.cs @@ -1,5 +1,7 @@ namespace Bit.Core.Enums; +// If the backing type here changes to a different type you will likely also need to change the value used in +// EncryptedStringAttribute public enum EncryptionType : byte { AesCbc256_B64 = 0, diff --git a/src/Core/Utilities/EncryptedStringAttribute.cs b/src/Core/Utilities/EncryptedStringAttribute.cs new file mode 100644 index 0000000000..0f672d1bdb --- /dev/null +++ b/src/Core/Utilities/EncryptedStringAttribute.cs @@ -0,0 +1,185 @@ +using System.Buffers; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics; +using Bit.Core.Enums; + +#nullable enable + +namespace Bit.Core.Utilities; + +/// +/// Validates a string that is in encrypted form: "head.b64iv=|b64ct=|b64mac=" +/// +public class EncryptedStringAttribute : ValidationAttribute +{ + private static readonly Dictionary _encryptionTypeMap; + + static EncryptedStringAttribute() + { + _encryptionTypeMap = new() + { + [EncryptionType.AesCbc256_B64] = 2, + [EncryptionType.AesCbc128_HmacSha256_B64] = 3, + [EncryptionType.AesCbc256_HmacSha256_B64] = 3, + [EncryptionType.Rsa2048_OaepSha256_B64] = 1, + [EncryptionType.Rsa2048_OaepSha1_B64] = 1, + [EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64] = 2, + [EncryptionType.Rsa2048_OaepSha1_HmacSha256_B64] = 2, + }; + +#if DEBUG + var enumValues = Enum.GetValues(); + Debug.Assert(enumValues.Length == _encryptionTypeMap.Count, + $"New {nameof(EncryptionType)} enums should be added to the {nameof(_encryptionTypeMap)}"); +#endif + } + + public EncryptedStringAttribute() + : base("{0} is not a valid encrypted string.") + { } + + public override bool IsValid(object? value) + { + try + { + if (value is null) + { + return true; + } + + if (value is string stringValue) + { + // Fast path + return IsValidCore(stringValue); + } + + // Slow path (ish) + return IsValidCore(value.ToString()); + } + catch + { + return false; + } + } + + internal static bool IsValidCore(ReadOnlySpan value) + { + if (!value.TrySplitBy('.', out var headerChunk, out var rest)) + { + // We coundn't find a header part, this is the slow path, because we have to do two loops over + // the data. + // If it has 3 encryption parts that means it is AesCbc128_HmacSha256_B64 + // else we assume it is AesCbc256_B64 + var encryptionPiecesChunk = rest; + + var pieces = 1; + var findIndex = encryptionPiecesChunk.IndexOf('|'); + + while(findIndex != -1) + { + pieces++; + encryptionPiecesChunk = encryptionPiecesChunk[++findIndex..]; + findIndex = encryptionPiecesChunk.IndexOf('|'); + } + + if (pieces == 3) + { + return ValidatePieces(rest, _encryptionTypeMap[EncryptionType.AesCbc128_HmacSha256_B64]); + } + else + { + return ValidatePieces(rest, _encryptionTypeMap[EncryptionType.AesCbc256_B64]); + } + } + + EncryptionType encryptionType; + + // Using byte here because that is the backing type for EncryptionType + if (!byte.TryParse(headerChunk, out var encryptionTypeNumber)) + { + // We can't read the header chunk as a number, this is the slow path + if (!Enum.TryParse(headerChunk, out encryptionType)) + { + // Can't even get the enum from a non-number header, fail + return false; + } + + // Since this value came from Enum.TryParse we know it is an enumerated object and we can therefore + // just access the dictionary + return ValidatePieces(rest, _encryptionTypeMap[encryptionType]); + } + + // Simply cast the number to the enum, this could be a value that doesn't actually have a backing enum + // entry but that is alright we will use it to look in the dictionary and non-valid + // numbers will be filtered out there. + encryptionType = (EncryptionType)encryptionTypeNumber; + + if (!_encryptionTypeMap.TryGetValue(encryptionType, out var encryptionPieces)) + { + // Could not find a configuration map for the given header piece. This is an invalid string + return false; + } + + return ValidatePieces(rest, encryptionPieces); + } + + private static bool ValidatePieces(ReadOnlySpan encryptionPart, int requiredPieces) + { + var rest = encryptionPart; + + while (requiredPieces != 0) + { + if (requiredPieces == 1) + { + if (!IsValidBase64(rest)) + { + return false; + } + + return rest.IndexOf('|') == -1; + } + else + { + // More than one part is required so split it out + if (!rest.TrySplitBy('|', out var chunk, out rest)) + { + return false; + } + + // Is the required chunk valid base 64? + if (!IsValidBase64(chunk)) + { + return false; + } + } + + requiredPieces--; + } + + // No more parts are required, so check there are no extra parts + return rest.IndexOf('|') == -1; + } + + private static bool IsValidBase64(ReadOnlySpan input) + { + byte[]? pooledChunks = null; + + // Ref: https://vcsjones.dev/stackalloc/ + var byteBuffer = input.Length > 128 + ? (pooledChunks = ArrayPool.Shared.Rent(input.Length * 2)) + : stackalloc byte[256]; + + try + { + var successful = Convert.TryFromBase64Chars(input, byteBuffer, out var bytesWritten); + return successful && bytesWritten > 0; + } + finally + { + if (pooledChunks != null) + { + ArrayPool.Shared.Return(pooledChunks); + } + } + } +} diff --git a/src/Core/Utilities/EncryptedValueAttribute.cs b/src/Core/Utilities/EncryptedValueAttribute.cs deleted file mode 100644 index ec0b218c5f..0000000000 --- a/src/Core/Utilities/EncryptedValueAttribute.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Bit.Core.Utilities; - -/// -/// Validates a string that is in encrypted form: "head.b64iv=|b64ct=|b64mac=" -/// -public class EncryptedStringAttribute : ValidationAttribute -{ - public EncryptedStringAttribute() - : base("{0} is not a valid encrypted string.") - { } - - public override bool IsValid(object value) - { - if (value == null) - { - return true; - } - - try - { - var encString = value?.ToString(); - if (string.IsNullOrWhiteSpace(encString)) - { - return false; - } - - var headerPieces = encString.Split('.'); - string[] encStringPieces = null; - var encType = Enums.EncryptionType.AesCbc256_B64; - - if (headerPieces.Length == 1) - { - encStringPieces = headerPieces[0].Split('|'); - if (encStringPieces.Length == 3) - { - encType = Enums.EncryptionType.AesCbc128_HmacSha256_B64; - } - else - { - encType = Enums.EncryptionType.AesCbc256_B64; - } - } - else if (headerPieces.Length == 2) - { - encStringPieces = headerPieces[1].Split('|'); - if (!Enum.TryParse(headerPieces[0], out encType)) - { - return false; - } - } - - switch (encType) - { - case Enums.EncryptionType.AesCbc256_B64: - case Enums.EncryptionType.Rsa2048_OaepSha1_HmacSha256_B64: - case Enums.EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64: - if (encStringPieces.Length != 2) - { - return false; - } - break; - case Enums.EncryptionType.AesCbc128_HmacSha256_B64: - case Enums.EncryptionType.AesCbc256_HmacSha256_B64: - if (encStringPieces.Length != 3) - { - return false; - } - break; - case Enums.EncryptionType.Rsa2048_OaepSha256_B64: - case Enums.EncryptionType.Rsa2048_OaepSha1_B64: - if (encStringPieces.Length != 1) - { - return false; - } - break; - default: - return false; - } - - switch (encType) - { - case Enums.EncryptionType.AesCbc256_B64: - case Enums.EncryptionType.AesCbc128_HmacSha256_B64: - case Enums.EncryptionType.AesCbc256_HmacSha256_B64: - var iv = Convert.FromBase64String(encStringPieces[0]); - var ct = Convert.FromBase64String(encStringPieces[1]); - if (iv.Length < 1 || ct.Length < 1) - { - return false; - } - - if (encType == Enums.EncryptionType.AesCbc128_HmacSha256_B64 || - encType == Enums.EncryptionType.AesCbc256_HmacSha256_B64) - { - var mac = Convert.FromBase64String(encStringPieces[2]); - if (mac.Length < 1) - { - return false; - } - } - - break; - case Enums.EncryptionType.Rsa2048_OaepSha256_B64: - case Enums.EncryptionType.Rsa2048_OaepSha1_B64: - case Enums.EncryptionType.Rsa2048_OaepSha1_HmacSha256_B64: - case Enums.EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64: - var rsaCt = Convert.FromBase64String(encStringPieces[0]); - if (rsaCt.Length < 1) - { - return false; - } - - if (encType == Enums.EncryptionType.Rsa2048_OaepSha1_HmacSha256_B64 || - encType == Enums.EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64) - { - var mac = Convert.FromBase64String(encStringPieces[1]); - if (mac.Length < 1) - { - return false; - } - } - - break; - default: - return false; - } - } - catch - { - return false; - } - - return true; - } -} diff --git a/src/Core/Utilities/SpanExtensions.cs b/src/Core/Utilities/SpanExtensions.cs new file mode 100644 index 0000000000..ee73ac87eb --- /dev/null +++ b/src/Core/Utilities/SpanExtensions.cs @@ -0,0 +1,21 @@ +namespace Bit.Core.Utilities; + +public static class SpanExtensions +{ + public static bool TrySplitBy(this ReadOnlySpan input, + char splitChar, out ReadOnlySpan chunk, out ReadOnlySpan rest) + { + var splitIndex = input.IndexOf(splitChar); + + if (splitIndex < 1) + { + chunk = default; + rest = input; + return false; + } + + chunk = input[..splitIndex]; + rest = input[++splitIndex..]; + return true; + } +} diff --git a/test/Core.Test/Utilities/EncryptedStringAttributeTests.cs b/test/Core.Test/Utilities/EncryptedStringAttributeTests.cs index c16a983cf9..194e817bfd 100644 --- a/test/Core.Test/Utilities/EncryptedStringAttributeTests.cs +++ b/test/Core.Test/Utilities/EncryptedStringAttributeTests.cs @@ -10,6 +10,20 @@ public class EncryptedStringAttributeTests [InlineData("aXY=|Y3Q=")] // Valid AesCbc256_B64 [InlineData("aXY=|Y3Q=|cnNhQ3Q=")] // Valid AesCbc128_HmacSha256_B64 [InlineData("Rsa2048_OaepSha256_B64.cnNhQ3Q=")] + [InlineData("0.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // Valid AesCbc256_B64 as a number + [InlineData("AesCbc256_B64.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // Valid AesCbc256_B64 as a number + [InlineData("1.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // Valid AesCbc128_HmacSha256_B64 as a number + [InlineData("AesCbc128_HmacSha256_B64.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // Valid AesCbc128_HmacSha256_B64 as a string + [InlineData("2.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // Valid AesCbc256_HmacSha256_B64 as a number + [InlineData("AesCbc256_HmacSha256_B64.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // Valid AesCbc256_HmacSha256_B64 as a string + [InlineData("3.QmFzZTY0UGFydA==")] // Valid Rsa2048_OaepSha256_B64 as a number + [InlineData("Rsa2048_OaepSha256_B64.QmFzZTY0UGFydA==")] // Valid Rsa2048_OaepSha256_B64 as a string + [InlineData("4.QmFzZTY0UGFydA==")] // Valid Rsa2048_OaepSha1_B64 as a number + [InlineData("Rsa2048_OaepSha1_B64.QmFzZTY0UGFydA==")] // Valid Rsa2048_OaepSha1_B64 as a string + [InlineData("5.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // Valid Rsa2048_OaepSha256_HmacSha256_B64 as a number + [InlineData("Rsa2048_OaepSha256_HmacSha256_B64.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // Valid Rsa2048_OaepSha256_HmacSha256_B64 as a string + [InlineData("6.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // Valid Rsa2048_OaepSha1_HmacSha256_B64 as a number + [InlineData("Rsa2048_OaepSha1_HmacSha256_B64.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] public void IsValid_ReturnsTrue_WhenValid(string input) { var sut = new EncryptedStringAttribute(); @@ -20,9 +34,10 @@ public class EncryptedStringAttributeTests } [Theory] - [InlineData("")] - [InlineData(".")] - [InlineData("|")] + [InlineData("")] // Empty string + [InlineData(".")] // Split Character but two empty parts + [InlineData("|")] // One encrypted part split character but empty parts + [InlineData("||")] // Two encrypted part split character but empty parts [InlineData("!|!")] // Invalid base 64 [InlineData("Rsa2048_OaepSha1_HmacSha256_B64.1")] // Invalid length [InlineData("Rsa2048_OaepSha1_HmacSha256_B64.|")] // Empty iv & ct @@ -31,6 +46,21 @@ public class EncryptedStringAttributeTests [InlineData("Rsa2048_OaepSha1_HmacSha256_B64.aXY=|Y3Q=|")] // Empty mac [InlineData("Rsa2048_OaepSha256_B64.1|2")] // Invalid length [InlineData("Rsa2048_OaepSha1_HmacSha256_B64.aXY=|")] // Empty mac + [InlineData("254.QmFzZTY0UGFydA==")] // Bad Encryption type number + [InlineData("0.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // Invalid AesCbc256_B64 as a number + [InlineData("AesCbc256_B64.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // Invalid AesCbc256_B64 as a number + [InlineData("1.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // Invalid AesCbc128_HmacSha256_B64 as a number + [InlineData("AesCbc128_HmacSha256_B64.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // Invalid AesCbc128_HmacSha256_B64 as a string + [InlineData("2.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // Invalid AesCbc256_HmacSha256_B64 as a number + [InlineData("AesCbc256_HmacSha256_B64.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // Invalid AesCbc256_HmacSha256_B64 as a string + [InlineData("3.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // Invalid Rsa2048_OaepSha256_B64 as a number + [InlineData("Rsa2048_OaepSha256_B64.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // Invalid Rsa2048_OaepSha256_B64 as a string + [InlineData("4.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // Invalid Rsa2048_OaepSha1_B64 as a number + [InlineData("Rsa2048_OaepSha1_B64.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // Invalid Rsa2048_OaepSha1_B64 as a string + [InlineData("5.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // Invalid Rsa2048_OaepSha256_HmacSha256_B64 as a number + [InlineData("Rsa2048_OaepSha256_HmacSha256_B64.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // Invalid Rsa2048_OaepSha256_HmacSha256_B64 as a string + [InlineData("6.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // Invalid Rsa2048_OaepSha1_HmacSha256_B64 as a number + [InlineData("Rsa2048_OaepSha1_HmacSha256_B64.QmFzZTY0UGFydA==")] // Invalid Rsa2048_OaepSha1_HmacSha256_B64 as a string public void IsValid_ReturnsFalse_WhenInvalid(string input) { var sut = new EncryptedStringAttribute();