File: HandshakeHelpers.cs
Web Access
Project: src\src\Middleware\WebSockets\src\Microsoft.AspNetCore.WebSockets.csproj (Microsoft.AspNetCore.WebSockets)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.Net.Http.Headers;
 
namespace Microsoft.AspNetCore.WebSockets;
 
internal static class HandshakeHelpers
{
    // This uses C# compiler's ability to refer to static data directly. For more information see https://vcsjones.dev/2019/02/01/csharp-readonly-span-bytes-static
    private static ReadOnlySpan<byte> EncodedWebSocketKey => "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"u8;
 
    public static void GenerateResponseHeaders(bool isHttp1, IHeaderDictionary requestHeaders, string? subProtocol, IHeaderDictionary responseHeaders)
    {
        if (isHttp1)
        {
            responseHeaders.Connection = HeaderNames.Upgrade;
            responseHeaders.Upgrade = Constants.Headers.UpgradeWebSocket;
            responseHeaders.SecWebSocketAccept = CreateResponseKey(requestHeaders.SecWebSocketKey.ToString());
        }
        if (!string.IsNullOrWhiteSpace(subProtocol))
        {
            responseHeaders.SecWebSocketProtocol = subProtocol;
        }
    }
 
    /// <summary>
    /// Validates the Sec-WebSocket-Key request header
    /// </summary>
    /// <param name="value"></param>
    /// <returns></returns>
    public static bool IsRequestKeyValid(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return false;
        }
 
        Span<byte> temp = stackalloc byte[16];
        var success = Convert.TryFromBase64String(value, temp, out var written);
        return success && written == 16;
    }
 
    public static string CreateResponseKey(string requestKey)
    {
        // "The value of this header field is constructed by concatenating /key/, defined above in step 4
        // in Section 4.2.2, with the string "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", taking the SHA-1 hash of
        // this concatenated value to obtain a 20-byte value and base64-encoding"
        // https://tools.ietf.org/html/rfc6455#section-4.2.2
 
        // requestKey is already verified to be small (24 bytes) by 'IsRequestKeyValid()' and everything is 1:1 mapping to UTF8 bytes
        // so this can be hardcoded to 60 bytes for the requestKey + static websocket string
        Span<byte> mergedBytes = stackalloc byte[60];
        Encoding.UTF8.GetBytes(requestKey, mergedBytes);
        EncodedWebSocketKey.CopyTo(mergedBytes[24..]);
 
        Span<byte> hashedBytes = stackalloc byte[20];
        var written = SHA1.HashData(mergedBytes, hashedBytes);
        if (written != 20)
        {
            throw new InvalidOperationException("Could not compute the hash for the 'Sec-WebSocket-Accept' header.");
        }
 
        return Convert.ToBase64String(hashedBytes);
    }
 
    // https://datatracker.ietf.org/doc/html/rfc7692#section-7.1
    public static bool ParseDeflateOptions(ReadOnlySpan<char> extension, bool serverContextTakeover,
        int serverMaxWindowBits, out WebSocketDeflateOptions parsedOptions, [NotNullWhen(true)] out string? response)
    {
        bool hasServerMaxWindowBits = false;
        bool hasClientMaxWindowBits = false;
        bool hasClientNoContext = false;
        bool hasServerNoContext = false;
        response = null;
        parsedOptions = new WebSocketDeflateOptions()
        {
            ServerContextTakeover = serverContextTakeover,
            ServerMaxWindowBits = serverMaxWindowBits
        };
 
        using var builder = new ValueStringBuilder(WebSocketDeflateConstants.MaxExtensionLength);
        builder.Append(WebSocketDeflateConstants.Extension);
 
        while (true)
        {
            int end = extension.IndexOf(';');
            ReadOnlySpan<char> value = (end >= 0 ? extension[..end] : extension).Trim();
 
            if (value.Length == 0)
            {
                break;
            }
 
            if (value.SequenceEqual(WebSocketDeflateConstants.ClientNoContextTakeover))
            {
                // https://datatracker.ietf.org/doc/html/rfc7692#section-7
                // MUST decline if:
                // The negotiation offer contains multiple extension parameters with
                // the same name.
                if (hasClientNoContext)
                {
                    return false;
                }
 
                hasClientNoContext = true;
                parsedOptions.ClientContextTakeover = false;
                builder.Append(';');
                builder.Append(' ');
                builder.Append(WebSocketDeflateConstants.ClientNoContextTakeover);
            }
            else if (value.SequenceEqual(WebSocketDeflateConstants.ServerNoContextTakeover))
            {
                // https://datatracker.ietf.org/doc/html/rfc7692#section-7
                // MUST decline if:
                // The negotiation offer contains multiple extension parameters with
                // the same name.
                if (hasServerNoContext)
                {
                    return false;
                }
 
                hasServerNoContext = true;
                parsedOptions.ServerContextTakeover = false;
            }
            else if (value.StartsWith(WebSocketDeflateConstants.ClientMaxWindowBits))
            {
                // https://datatracker.ietf.org/doc/html/rfc7692#section-7
                // MUST decline if:
                // The negotiation offer contains multiple extension parameters with
                // the same name.
                if (hasClientMaxWindowBits)
                {
                    return false;
                }
 
                hasClientMaxWindowBits = true;
                if (!ParseWindowBits(value, out var clientMaxWindowBits))
                {
                    return false;
                }
 
                // 8 is a valid value according to the spec, but our zlib implementation does not support it
                if (clientMaxWindowBits == 8)
                {
                    return false;
                }
 
                // https://tools.ietf.org/html/rfc7692#section-7.1.2.2
                // the server may either ignore this
                // value or use this value to avoid allocating an unnecessarily big LZ77
                // sliding window by including the "client_max_window_bits" extension
                // parameter in the corresponding extension negotiation response to the
                // offer with a value equal to or smaller than the received value.
                parsedOptions.ClientMaxWindowBits = clientMaxWindowBits ?? 15;
 
                // If a received extension negotiation offer doesn't have the
                // "client_max_window_bits" extension parameter, the corresponding
                // extension negotiation response to the offer MUST NOT include the
                // "client_max_window_bits" extension parameter.
                builder.Append(';');
                builder.Append(' ');
                builder.Append(WebSocketDeflateConstants.ClientMaxWindowBits);
                builder.Append('=');
                var len = (parsedOptions.ClientMaxWindowBits > 9) ? 2 : 1;
                var span = builder.AppendSpan(len);
                var ret = parsedOptions.ClientMaxWindowBits.TryFormat(span, out var written, provider: CultureInfo.InvariantCulture);
                Debug.Assert(ret);
                Debug.Assert(written == len);
            }
            else if (value.StartsWith(WebSocketDeflateConstants.ServerMaxWindowBits))
            {
                // https://datatracker.ietf.org/doc/html/rfc7692#section-7
                // MUST decline if:
                // The negotiation offer contains multiple extension parameters with
                // the same name.
                if (hasServerMaxWindowBits)
                {
                    return false;
                }
 
                hasServerMaxWindowBits = true;
                if (!ParseWindowBits(value, out var parsedServerMaxWindowBits))
                {
                    return false;
                }
 
                // 8 is a valid value according to the spec, but our zlib implementation does not support it
                if (parsedServerMaxWindowBits == 8)
                {
                    return false;
                }
 
                // https://tools.ietf.org/html/rfc7692#section-7.1.2.1
                // A server accepts an extension negotiation offer with this parameter
                // by including the "server_max_window_bits" extension parameter in the
                // extension negotiation response to send back to the client with the
                // same or smaller value as the offer.
                parsedOptions.ServerMaxWindowBits = Math.Min(parsedServerMaxWindowBits ?? 15, serverMaxWindowBits);
            }
 
            static bool ParseWindowBits(ReadOnlySpan<char> value, out int? parsedValue)
            {
                var startIndex = value.IndexOf('=');
 
                // parameters can be sent without a value by the client, we'll use the values set by the app developer or the default of 15
                if (startIndex < 0)
                {
                    parsedValue = null;
                    return true;
                }
 
                value = value[(startIndex + 1)..].TrimEnd();
 
                if (value.Length == 0)
                {
                    parsedValue = null;
                    return false;
                }
 
                // https://datatracker.ietf.org/doc/html/rfc7692#section-5.2
                // check for value in quotes and pull the value out without the quotes
                if (value[0] == '"' && value.EndsWith("\"".AsSpan()) && value.Length > 1)
                {
                    value = value[1..^1];
                }
 
                if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int windowBits) ||
                    windowBits < 8 ||
                    windowBits > 15)
                {
                    parsedValue = null;
                    return false;
                }
 
                parsedValue = windowBits;
                return true;
            }
 
            if (end < 0)
            {
                break;
            }
            extension = extension[(end + 1)..];
        }
 
        if (!parsedOptions.ServerContextTakeover)
        {
            builder.Append(';');
            builder.Append(' ');
            builder.Append(WebSocketDeflateConstants.ServerNoContextTakeover);
        }
 
        if (hasServerMaxWindowBits || parsedOptions.ServerMaxWindowBits != 15)
        {
            builder.Append(';');
            builder.Append(' ');
            builder.Append(WebSocketDeflateConstants.ServerMaxWindowBits);
            builder.Append('=');
            var len = (parsedOptions.ServerMaxWindowBits > 9) ? 2 : 1;
            var span = builder.AppendSpan(len);
            var ret = parsedOptions.ServerMaxWindowBits.TryFormat(span, out var written, provider: CultureInfo.InvariantCulture);
            Debug.Assert(ret);
            Debug.Assert(written == len);
        }
 
        response = builder.ToString();
 
        return true;
    }
}