using System.Buffers; using System.Buffers.Text; using System.Text.Json; using System.Text.Json.Serialization; namespace ProtonVPN.OperatingSystems.WebAuthn.Serialization; /// /// Custom converter for encoding/decoding byte[] using Base64Url instead of default Base64. /// public sealed class Base64UrlConverter : JsonConverter { public override byte[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.HasValueSequence) { return FromBase64UrlString(reader.GetString()); } else { return FromBase64UrlString(reader.ValueSpan); } } public override void Write(Utf8JsonWriter writer, byte[] value, JsonSerializerOptions options) { writer?.WriteStringValue(ToBase64UrlString(value)); } /// /// Converts a byte array to a Base64Url encoded string /// /// The byte array to convert /// The Base64Url encoded form of the input #pragma warning disable CA1055 // URI-like return values should not be strings public static string ToBase64UrlString(byte[] input) #pragma warning restore CA1055 // URI-like return values should not be strings { if (input == null) { throw new ArgumentNullException(nameof(input)); } return Convert.ToBase64String(input). TrimEnd('='). Replace('+', '-'). Replace('/', '_'); } /// /// Converts a Base64Url encoded string to a byte array /// /// The Base64Url encoded string /// The byte array represented by the encoded string public static byte[] FromBase64UrlString(string input) { if (input == null) { throw new ArgumentNullException(nameof(input)); } return Convert.FromBase64String(Pad(input.Replace('-', '+').Replace('_', '/'))); } /// /// Converts a Base64Url encoded string to a byte array /// /// The Base64Url encoded string /// The byte array represented by the encoded string public static byte[] FromBase64UrlString(ReadOnlySpan input) { if (input == null) { throw new ArgumentNullException(nameof(input)); } // START temporary workaround for MSFT bug (https://github.com/MichaelGrafnetter/webauthn-interop/issues/21) // Checking to see if the last character is a digit and if we removed it, would there be anything left if (input.Length > 1 && char.IsDigit((char)input[input.Length - 1])) { // Looking for last character to be 0, 1, or 2 char lastChar = (char)input[input.Length - 1]; // If we removed the last character, calculate the padding required for the remaining string int potentialPaddingLength = (input.Length - 1) % 4; // If the last character matches the padding length of the remaining string, this is very likely the case we are looking for if (lastChar == '0' && potentialPaddingLength == 0 || lastChar == '1' && potentialPaddingLength == 3 || lastChar == '2' && potentialPaddingLength == 2) { // Update the input to remove the last character input = input.Slice(0, input.Length - 1); } } // END temporary workaround int paddingLength = (input.Length % 4) switch { 0 => 0, // Padding is not needed 2 => 2, // "==" missing in Base64Url vs. Base64 3 => 1, // "=" missing in Base64Url vs. Base64 _ => throw new ArgumentException("Illegal Base64URL string!", nameof(input)) }; // Pad the input to be compatible with BASE64 int binaryLength = input.Length + paddingLength; byte[] result = new byte[binaryLength]; input.CopyTo(result); // Translate Base64Url chars to BASE64 chars for (int i = 0; i < binaryLength; i++) { if ((char)result[i] == '-') { // Replace '-' with '+' result[i] = (byte)'+'; } else if ((char)result[i] == '_') { // Replace '_' with '/' result[i] = (byte)'/'; } } // Add padding ("" or "=" or "==") for (int i = binaryLength - paddingLength; i < binaryLength; i++) { result[i] = (byte)'='; } OperationStatus status = Base64.DecodeFromUtf8InPlace(result, out int bytesWritten); if (status != OperationStatus.Done) { throw new ArgumentException("Illegal Base64URL string!", nameof(input)); } return new Span(result, 0, bytesWritten).ToArray(); } /// /// Adds padding to the input /// /// the input string /// the padded string private static string Pad(string input) { if (input.TrimEnd().EndsWith("=", StringComparison.InvariantCulture)) { throw new ArgumentException("Illegal Base64URL string!", nameof(input)); } switch (input.Length % 4) { case 0: // Padding is not needed break; case 2: input += "=="; break; case 3: input += "="; break; default: throw new ArgumentException("Illegal Base64URL string!", nameof(input)); } return input; } }