diff --git a/src/Microsoft.AspNetCore.WebUtilities/Resources.Designer.cs b/src/Microsoft.AspNetCore.WebUtilities/Resources.Designer.cs index d8071d1a4f..7972e005d0 100644 --- a/src/Microsoft.AspNetCore.WebUtilities/Resources.Designer.cs +++ b/src/Microsoft.AspNetCore.WebUtilities/Resources.Designer.cs @@ -1,4 +1,4 @@ -//------------------------------------------------------------------------------ +//------------------------------------------------------------------------------ // // This code was generated by a tool. // Runtime Version:4.0.30319.42000 @@ -76,5 +76,14 @@ namespace Microsoft.AspNetCore.WebUtilities { return ResourceManager.GetString("HttpResponseStreamWriter_StreamNotWritable", resourceCulture); } } + + /// + /// Looks up a localized string similar to Invalid {0}, {1} or {2} length.. + /// + internal static string WebEncoders_InvalidCountOffsetOrLength { + get { + return ResourceManager.GetString("WebEncoders_InvalidCountOffsetOrLength", resourceCulture); + } + } } } diff --git a/src/Microsoft.AspNetCore.WebUtilities/Resources.resx b/src/Microsoft.AspNetCore.WebUtilities/Resources.resx index 9873865894..a32d2db5cc 100644 --- a/src/Microsoft.AspNetCore.WebUtilities/Resources.resx +++ b/src/Microsoft.AspNetCore.WebUtilities/Resources.resx @@ -1,17 +1,17 @@  - @@ -123,4 +123,7 @@ The stream must support writing. + + Invalid {0}, {1} or {2} length. + \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.WebUtilities/WebEncoders.cs b/src/Microsoft.AspNetCore.WebUtilities/WebEncoders.cs index dc462135d1..e317d924eb 100644 --- a/src/Microsoft.AspNetCore.WebUtilities/WebEncoders.cs +++ b/src/Microsoft.AspNetCore.WebUtilities/WebEncoders.cs @@ -3,6 +3,7 @@ using System; using System.Diagnostics; +using System.Globalization; namespace Microsoft.AspNetCore.WebUtilities { @@ -11,6 +12,8 @@ namespace Microsoft.AspNetCore.WebUtilities /// public static class WebEncoders { + private static readonly byte[] EmptyBytes = new byte[0]; + /// /// Decodes a base64url-encoded string. /// @@ -18,7 +21,7 @@ namespace Microsoft.AspNetCore.WebUtilities /// The base64url-decoded form of the input. /// /// The input must not contain any whitespace or padding characters. - /// Throws FormatException if the input is malformed. + /// Throws if the input is malformed. /// public static byte[] Base64UrlDecode(string input) { @@ -27,7 +30,7 @@ namespace Microsoft.AspNetCore.WebUtilities throw new ArgumentNullException(nameof(input)); } - return Base64UrlDecode(input, 0, input.Length); + return Base64UrlDecode(input, offset: 0, count: input.Length); } /// @@ -39,7 +42,7 @@ namespace Microsoft.AspNetCore.WebUtilities /// The base64url-decoded form of the input. /// /// The input must not contain any whitespace or padding characters. - /// Throws FormatException if the input is malformed. + /// Throws if the input is malformed. /// public static byte[] Base64UrlDecode(string input, int offset, int count) { @@ -48,50 +51,139 @@ namespace Microsoft.AspNetCore.WebUtilities throw new ArgumentNullException(nameof(input)); } - ValidateParameters(input.Length, offset, count); + ValidateParameters(input.Length, nameof(input), offset, count); // Special-case empty input if (count == 0) { - return new byte[0]; + return EmptyBytes; + } + + // Create array large enough for the Base64 characters, not just shorter Base64-URL-encoded form. + var buffer = new char[GetArraySizeRequiredToDecode(count)]; + + return Base64UrlDecode(input, offset, buffer, bufferOffset: 0, count: count); + } + + /// + /// Decodes a base64url-encoded into a byte[]. + /// + /// A string containing the base64url-encoded input to decode. + /// The position in at which decoding should begin. + /// + /// Scratch buffer to hold the s to decode. Array must be large enough to hold + /// and characters as well as Base64 padding + /// characters. Content is not preserved. + /// + /// + /// The offset into at which to begin writing the s to decode. + /// + /// The number of characters in to decode. + /// The base64url-decoded form of the . + /// + /// The input must not contain any whitespace or padding characters. + /// Throws if the input is malformed. + /// + public static byte[] Base64UrlDecode(string input, int offset, char[] buffer, int bufferOffset, int count) + { + if (input == null) + { + throw new ArgumentNullException(nameof(input)); + } + if (buffer == null) + { + throw new ArgumentNullException(nameof(buffer)); + } + + ValidateParameters(input.Length, nameof(input), offset, count); + if (bufferOffset < 0) + { + throw new ArgumentOutOfRangeException(nameof(bufferOffset)); + } + + if (count == 0) + { + return EmptyBytes; } // Assumption: input is base64url encoded without padding and contains no whitespace. - // First, we need to add the padding characters back. - int numPaddingCharsToAdd = GetNumBase64PaddingCharsToAddForDecode(count); - char[] completeBase64Array = new char[checked(count + numPaddingCharsToAdd)]; - Debug.Assert(completeBase64Array.Length % 4 == 0, "Invariant: Array length must be a multiple of 4."); - input.CopyTo(offset, completeBase64Array, 0, count); - for (int i = 1; i <= numPaddingCharsToAdd; i++) + var paddingCharsToAdd = GetNumBase64PaddingCharsToAddForDecode(count); + var arraySizeRequired = checked(count + paddingCharsToAdd); + Debug.Assert(arraySizeRequired % 4 == 0, "Invariant: Array length must be a multiple of 4."); + + if (buffer.Length - bufferOffset < arraySizeRequired) { - completeBase64Array[completeBase64Array.Length - i] = '='; + throw new ArgumentException( + string.Format( + CultureInfo.CurrentCulture, + Resources.WebEncoders_InvalidCountOffsetOrLength, + nameof(count), + nameof(bufferOffset), + nameof(input)), + nameof(count)); } - // Next, fix up '-' -> '+' and '_' -> '/' - for (int i = 0; i < completeBase64Array.Length; i++) + // Copy input into buffer, fixing up '-' -> '+' and '_' -> '/'. + var i = bufferOffset; + for (var j = offset; i - bufferOffset < count; i++, j++) { - char c = completeBase64Array[i]; - if (c == '-') + var ch = input[j]; + if (ch == '-') { - completeBase64Array[i] = '+'; + buffer[i] = '+'; } - else if (c == '_') + else if (ch == '_') { - completeBase64Array[i] = '/'; + buffer[i] = '/'; + } + else + { + buffer[i] = ch; } } - // Finally, decode. + // Add the padding characters back. + for (; paddingCharsToAdd > 0; i++, paddingCharsToAdd--) + { + buffer[i] = '='; + } + + // Decode. // If the caller provided invalid base64 chars, they'll be caught here. - return Convert.FromBase64CharArray(completeBase64Array, 0, completeBase64Array.Length); + return Convert.FromBase64CharArray(buffer, bufferOffset, arraySizeRequired); } /// - /// Encodes an input using base64url encoding. + /// Gets the minimum char[] size required for decoding of characters + /// with the method. + /// + /// The number of characters to decode. + /// + /// The minimum char[] size required for decoding of characters. + /// + public static int GetArraySizeRequiredToDecode(int count) + { + if (count < 0) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + + if (count == 0) + { + return 0; + } + + var numPaddingCharsToAdd = GetNumBase64PaddingCharsToAddForDecode(count); + + return checked(count + numPaddingCharsToAdd); + } + + /// + /// Encodes using base64url encoding. /// /// The binary input to encode. - /// The base64url-encoded form of the input. + /// The base64url-encoded form of . public static string Base64UrlEncode(byte[] input) { if (input == null) @@ -99,16 +191,16 @@ namespace Microsoft.AspNetCore.WebUtilities throw new ArgumentNullException(nameof(input)); } - return Base64UrlEncode(input, 0, input.Length); + return Base64UrlEncode(input, offset: 0, count: input.Length); } /// - /// Encodes an input using base64url encoding. + /// Encodes using base64url encoding. /// /// The binary input to encode. /// The offset into at which to begin encoding. - /// The number of bytes of to encode. - /// The base64url-encoded form of the input. + /// The number of bytes from to encode. + /// The base64url-encoded form of . public static string Base64UrlEncode(byte[] input, int offset, int count) { if (input == null) @@ -116,7 +208,7 @@ namespace Microsoft.AspNetCore.WebUtilities throw new ArgumentNullException(nameof(input)); } - ValidateParameters(input.Length, offset, count); + ValidateParameters(input.Length, nameof(input), offset, count); // Special-case empty input if (count == 0) @@ -124,38 +216,104 @@ namespace Microsoft.AspNetCore.WebUtilities return string.Empty; } - // We're going to use base64url encoding with no padding characters. - // See RFC 4648, Sec. 5. - char[] buffer = new char[GetNumBase64CharsRequiredForInput(count)]; - int numBase64Chars = Convert.ToBase64CharArray(input, offset, count, buffer, 0); + var buffer = new char[GetArraySizeRequiredToEncode(count)]; + var numBase64Chars = Base64UrlEncode(input, offset, buffer, outputOffset: 0, count: count); - // Fix up '+' -> '-' and '/' -> '_' - for (int i = 0; i < numBase64Chars; i++) + return new String(buffer, startIndex: 0, length: numBase64Chars); + } + + /// + /// Encodes using base64url encoding. + /// + /// The binary input to encode. + /// The offset into at which to begin encoding. + /// + /// Buffer to receive the base64url-encoded form of . Array must be large enough to + /// hold characters and the full base64-encoded form of + /// , including padding characters. + /// + /// + /// The offset into at which to begin writing the base64url-encoded form of + /// . + /// + /// The number of bytes from to encode. + /// + /// The number of characters written to , less any padding characters. + /// + public static int Base64UrlEncode(byte[] input, int offset, char[] output, int outputOffset, int count) + { + if (input == null) { - char ch = buffer[i]; + throw new ArgumentNullException(nameof(input)); + } + if (output == null) + { + throw new ArgumentNullException(nameof(output)); + } + + ValidateParameters(input.Length, nameof(input), offset, count); + if (outputOffset < 0) + { + throw new ArgumentOutOfRangeException(nameof(outputOffset)); + } + + var arraySizeRequired = GetArraySizeRequiredToEncode(count); + if (output.Length - outputOffset < arraySizeRequired) + { + throw new ArgumentException( + string.Format( + CultureInfo.CurrentCulture, + Resources.WebEncoders_InvalidCountOffsetOrLength, + nameof(count), + nameof(outputOffset), + nameof(output)), + nameof(count)); + } + + // Special-case empty input. + if (count == 0) + { + return 0; + } + + // Use base64url encoding with no padding characters. See RFC 4648, Sec. 5. + + // Start with default Base64 encoding. + var numBase64Chars = Convert.ToBase64CharArray(input, offset, count, output, outputOffset); + + // Fix up '+' -> '-' and '/' -> '_'. Drop padding characters. + for (var i = outputOffset; i - outputOffset < numBase64Chars; i++) + { + var ch = output[i]; if (ch == '+') { - buffer[i] = '-'; + output[i] = '-'; } else if (ch == '/') { - buffer[i] = '_'; + output[i] = '_'; } else if (ch == '=') { - // We've reached a padding character: truncate the string from this point - return new String(buffer, 0, i); + // We've reached a padding character; truncate the remainder. + return i - outputOffset; } } - // If we got this far, the buffer didn't contain any padding chars, so turn - // it directly into a string. - return new String(buffer, 0, numBase64Chars); + return numBase64Chars; } - private static int GetNumBase64CharsRequiredForInput(int inputLength) + /// + /// Get the minimum output char[] size required for encoding + /// s with the method. + /// + /// The number of characters to encode. + /// + /// The minimum output char[] size required for encoding s. + /// + public static int GetArraySizeRequiredToEncode(int count) { - int numWholeOrPartialInputBlocks = checked(inputLength + 2) / 3; + var numWholeOrPartialInputBlocks = checked(count + 2) / 3; return checked(numWholeOrPartialInputBlocks * 4); } @@ -190,7 +348,7 @@ namespace Microsoft.AspNetCore.WebUtilities } } - private static void ValidateParameters(int bufferLength, int offset, int count) + private static void ValidateParameters(int bufferLength, string inputName, int offset, int count) { if (offset < 0) { @@ -202,7 +360,14 @@ namespace Microsoft.AspNetCore.WebUtilities } if (bufferLength - offset < count) { - throw new ArgumentException("Invalid offset / length."); + throw new ArgumentException( + string.Format( + CultureInfo.CurrentCulture, + Resources.WebEncoders_InvalidCountOffsetOrLength, + nameof(count), + nameof(offset), + inputName), + nameof(count)); } } } diff --git a/test/Microsoft.AspNetCore.WebUtilities.Tests/WebEncodersTests.cs b/test/Microsoft.AspNetCore.WebUtilities.Tests/WebEncodersTests.cs index 8804644e12..c781805fcc 100644 --- a/test/Microsoft.AspNetCore.WebUtilities.Tests/WebEncodersTests.cs +++ b/test/Microsoft.AspNetCore.WebUtilities.Tests/WebEncodersTests.cs @@ -57,6 +57,41 @@ namespace Microsoft.AspNetCore.WebUtilities Assert.Equal(roundTrippedAsBase64, base64Input); } + [Theory] + [InlineData("", "")] + [InlineData("123456qwerty++//X+/x", "123456qwerty--__X-_x")] + [InlineData("123456qwerty++//X+/xxw==", "123456qwerty--__X-_xxw")] + [InlineData("123456qwerty++//X+/xxw0=", "123456qwerty--__X-_xxw0")] + public void Base64UrlEncode_And_Decode_WithBufferOffsets(string base64Input, string expectedBase64Url) + { + // Arrange + var input = new byte[3].Concat(Convert.FromBase64String(base64Input)).Concat(new byte[2]).ToArray(); + var buffer = new char[30]; + var output = new char[30]; + for (var i = 0; i < buffer.Length; i++) + { + buffer[i] = '^'; + output[i] = '^'; + } + + // Act 1 + var numEncodedChars = + WebEncoders.Base64UrlEncode(input, offset: 3, output: output, outputOffset: 4, count: input.Length - 5); + + // Assert 1 + var encodedString = new string(output, startIndex: 4, length: numEncodedChars); + Assert.Equal(expectedBase64Url, encodedString); + + // Act 2 + var roundTripInput = new string(output); + var roundTripped = + WebEncoders.Base64UrlDecode(roundTripInput, offset: 4, buffer: buffer, bufferOffset: 5, count: numEncodedChars); + + // Assert 2, verify that values round-trip + var roundTrippedAsBase64 = Convert.ToBase64String(roundTripped); + Assert.Equal(roundTrippedAsBase64, base64Input); + } + [Theory] [InlineData(0, 1, 0)] [InlineData(0, 0, 1)]