Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions src/Core/Utilities/EncryptedStringAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ private static bool ValidatePieces(ReadOnlySpan<char> encryptionPart, int requir
if (requiredPieces == 1)
{
// Only one more part is needed so don't split and check the chunk
if (rest.IsEmpty || !Base64.IsValid(rest))
if (rest.IsEmpty || !IsValidBase64Permissive(rest))
{
return false;
}
Expand All @@ -128,7 +128,7 @@ private static bool ValidatePieces(ReadOnlySpan<char> encryptionPart, int requir
}

// Is the required chunk valid base 64?
if (chunk.IsEmpty || !Base64.IsValid(chunk))
if (chunk.IsEmpty || !IsValidBase64Permissive(chunk))
{
return false;
}
Expand All @@ -141,4 +141,57 @@ private static bool ValidatePieces(ReadOnlySpan<char> encryptionPart, int requir
// No more parts are required, so check there are no extra parts
return rest.IndexOf('|') == -1;
}

private const string _base64Chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

private static int Base64CharValue(char c) => c switch
{
>= 'A' and <= 'Z' => c - 'A',
>= 'a' and <= 'z' => c - 'a' + 26,
>= '0' and <= '9' => c - '0' + 52,
'+' => 62,
'/' => 63,
_ => -1,
};

/// <summary>
/// Validates base64 permissively by accepting non-zero padding bits.
/// </summary>
private static bool IsValidBase64Permissive(ReadOnlySpan<char> value)
{
// Obviously not base64
if (value.IsEmpty || value.Length % 4 != 0)
return false;

// If there isn't any padding, there's nothing to be permissive about.
var padCount = 0;
if (value[^1] == '=') { padCount++; if (value[^2] == '=') padCount++; }
if (padCount == 0)
return Base64.IsValid(value);

// Get the last non-padding char. Ensure it's in the base64 alphabet.
var lastDataIdx = value.Length - padCount - 1;
var charVal = Base64CharValue(value[lastDataIdx]);
if (charVal < 0)
return false;

// Compute the correct char. If the original char is already valid,
// test the full string.
var dataBitMask = padCount == 2 ? 0b110000 : 0b111100;
var newCharVal = charVal & dataBitMask;
if (newCharVal == charVal)
return Base64.IsValid(value);

// Validate all but the last block, to minimize allocation in the next
// section.
if (value.Length > 4 && !Base64.IsValid(value[..^4]))
return false;

// Apply the correct char and validate the last block
Span<char> canonical = stackalloc char[4];
value[^4..].CopyTo(canonical);
canonical[4 - padCount - 1] = _base64Chars[newCharVal];
return Base64.IsValid(canonical);
}
}
8 changes: 8 additions & 0 deletions test/Core.Test/Utilities/EncryptedStringAttributeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ public class EncryptedStringAttributeTests
[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==")]
[InlineData("0.AAAA|Y3Q=")] // Unpadded IV with padded CT
[InlineData("lGD=|lGD=")] // Non-canonical = padding on both pieces (headerless)
[InlineData("lB==|Y3Q=|AAAA")] // Non-canonical == padding headerless (3 pieces)
[InlineData("2.lGD=|lGD=|lGD=")] // Non-canonical = padding on all three pieces
[InlineData("0.AAAA|QmFzZTY0UGFydB==")] // Unpadded IV, non-canonical == in longer piece (exercises prefix validation)
public void IsValid_ReturnsTrue_WhenValid(string? input)
{
var sut = new EncryptedStringAttribute();
Expand Down Expand Up @@ -65,6 +70,9 @@ public void IsValid_ReturnsTrue_WhenValid(string? input)
[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
[InlineData("0.AA!!AB==|Y3Q=")] // Invalid char in prefix with non-canonical last char
[InlineData("0.AAAAB==|Y3Q=")] // Piece length not multiple of 4
[InlineData("0.====|Y3Q=")] // Padding-only piece
public void IsValid_ReturnsFalse_WhenInvalid(string input)
{
var sut = new EncryptedStringAttribute();
Expand Down
Loading