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();