File: Utils\BrowserToolsKeyPair.cs
Web Access
Project: Microsoft.NET.Sdk.StaticWebAssets.Tasks.csproj (Microsoft.NET.Sdk.StaticWebAssets.Tasks)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

#nullable disable

using System.Security.Cryptography;
using System.Text.Json;

namespace Microsoft.AspNetCore.StaticWebAssets.Tasks.Utils;

/// <summary>
/// Reads and writes the two JSON documents that describe the <c>dotnet watch</c> browser tools key
/// pair. The public document is the only half that is ever pinned into the application build output;
/// the private document is consumed exclusively by <c>dotnet watch</c> to key its provider.
///
/// Both documents are versioned so that a key produced by an older SDK is treated as invalid rather
/// than misinterpreted, and both carry the same base64 X.509 SubjectPublicKeyInfo value so that a
/// mismatch between the two files can be detected without deriving anything.
///
/// The representation of the private half is the raw <see cref="RSAParameters"/> components rather
/// than PKCS#8 because the task runs on .NET Framework as well, where the PKCS#8 import and export
/// APIs are not available.
/// </summary>
internal static class BrowserToolsKeyPair
{
    public const int CurrentVersion = 1;

    /// <summary>
    /// The padding the browser uses to encrypt the shared secret and the provider uses to decrypt
    /// it. Recorded so that a future change of algorithm invalidates existing key files.
    /// </summary>
    public const string Algorithm = "RSA-OAEP-SHA256";

    public const string PublicKeyFormat = "SubjectPublicKeyInfo";
    public const string PrivateKeyFormat = "RSAParameters";

    public const int KeySizeInBits = 2048;

    /// <summary>
    /// Result of reading a key document. <see cref="IsValid"/> is false for anything that is not a
    /// well formed, current version document; the caller regenerates in that case. The failure
    /// reason never contains key material.
    /// </summary>
    public readonly struct ReadResult(bool isValid, string publicKey, RSAParameters parameters, string reason)
    {
        public bool IsValid { get; } = isValid;
        public string PublicKey { get; } = publicKey;
        public RSAParameters Parameters { get; } = parameters;
        public string Reason { get; } = reason;

        public static ReadResult Invalid(string reason) => new(false, null, default, reason);
    }

    public static ReadResult TryReadPublicKeyFile(string path)
    {
        try
        {
            if (!File.Exists(path))
            {
                return ReadResult.Invalid("the file does not exist");
            }

            using var document = JsonDocument.Parse(File.ReadAllBytes(path));
            var root = document.RootElement;
            if (root.ValueKind != JsonValueKind.Object)
            {
                return ReadResult.Invalid("the document is not a JSON object");
            }

            if (!TryValidateHeader(root, PublicKeyFormat, out var headerError))
            {
                return ReadResult.Invalid(headerError);
            }

            if (!TryGetBase64(root, "publicKey", out var publicKey, out _))
            {
                return ReadResult.Invalid("the 'publicKey' property is missing or is not base64");
            }

            return new ReadResult(true, publicKey, default, null);
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
        {
            return ReadResult.Invalid($"the file could not be read ({ex.GetType().Name})");
        }
    }

    public static ReadResult TryReadPrivateKeyFile(string path)
    {
        try
        {
            if (!File.Exists(path))
            {
                return ReadResult.Invalid("the file does not exist");
            }

            using var document = JsonDocument.Parse(File.ReadAllBytes(path));
            var root = document.RootElement;
            if (root.ValueKind != JsonValueKind.Object)
            {
                return ReadResult.Invalid("the document is not a JSON object");
            }

            if (!TryValidateHeader(root, PrivateKeyFormat, out var headerError))
            {
                return ReadResult.Invalid(headerError);
            }

            if (!TryGetBase64(root, "publicKey", out var publicKey, out _))
            {
                return ReadResult.Invalid("the 'publicKey' property is missing or is not base64");
            }

            if (!root.TryGetProperty("parameters", out var parameters) || parameters.ValueKind != JsonValueKind.Object)
            {
                return ReadResult.Invalid("the 'parameters' property is missing or is not an object");
            }

            var rsaParameters = new RSAParameters();
            foreach (var (name, setter) in ParameterSetters)
            {
                if (!TryGetBase64(parameters, name, out _, out var value))
                {
                    return ReadResult.Invalid($"the private key component '{name}' is missing or is not base64");
                }

                setter(ref rsaParameters, value);
            }

            return new ReadResult(true, publicKey, rsaParameters, null);
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
        {
            return ReadResult.Invalid($"the file could not be read ({ex.GetType().Name})");
        }
    }

    private delegate void ParameterSetter(ref RSAParameters parameters, byte[] value);

    private static readonly (string Name, ParameterSetter Setter)[] ParameterSetters =
    [
        ("modulus", static (ref RSAParameters p, byte[] v) => p.Modulus = v),
        ("exponent", static (ref RSAParameters p, byte[] v) => p.Exponent = v),
        ("d", static (ref RSAParameters p, byte[] v) => p.D = v),
        ("p", static (ref RSAParameters p, byte[] v) => p.P = v),
        ("q", static (ref RSAParameters p, byte[] v) => p.Q = v),
        ("dp", static (ref RSAParameters p, byte[] v) => p.DP = v),
        ("dq", static (ref RSAParameters p, byte[] v) => p.DQ = v),
        ("inverseQ", static (ref RSAParameters p, byte[] v) => p.InverseQ = v),
    ];

    private static bool TryValidateHeader(JsonElement root, string expectedFormat, out string error)
    {
        if (!root.TryGetProperty("version", out var version) || version.ValueKind != JsonValueKind.Number || !version.TryGetInt32(out var versionValue))
        {
            error = "the 'version' property is missing or is not a number";
            return false;
        }

        if (versionValue != CurrentVersion)
        {
            error = $"the document version '{versionValue}' is not supported";
            return false;
        }

        if (!root.TryGetProperty("algorithm", out var algorithm) || algorithm.ValueKind != JsonValueKind.String || algorithm.GetString() != Algorithm)
        {
            error = "the 'algorithm' property is missing or does not match the expected algorithm";
            return false;
        }

        if (!root.TryGetProperty("format", out var format) || format.ValueKind != JsonValueKind.String || format.GetString() != expectedFormat)
        {
            error = "the 'format' property is missing or does not match the expected format";
            return false;
        }

        error = null;
        return true;
    }

    private static bool TryGetBase64(JsonElement element, string propertyName, out string text, out byte[] value)
    {
        text = null;
        value = null;

        if (!element.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.String)
        {
            return false;
        }

        var candidate = property.GetString();
        if (string.IsNullOrEmpty(candidate))
        {
            return false;
        }

        try
        {
            value = Convert.FromBase64String(candidate);
        }
        catch (FormatException)
        {
            return false;
        }

        text = candidate;
        return true;
    }

    public static byte[] CreatePublicKeyDocument(string publicKey)
    {
        using var buffer = new MemoryStream();
        using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = true }))
        {
            writer.WriteStartObject();
            WriteHeader(writer, PublicKeyFormat);
            writer.WriteString("publicKey", publicKey);
            writer.WriteEndObject();
        }

        return buffer.ToArray();
    }

    public static byte[] CreatePrivateKeyDocument(string publicKey, RSAParameters parameters)
    {
        using var buffer = new MemoryStream();
        using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = true }))
        {
            writer.WriteStartObject();
            WriteHeader(writer, PrivateKeyFormat);
            writer.WriteString("publicKey", publicKey);
            writer.WriteStartObject("parameters");
            writer.WriteString("modulus", Convert.ToBase64String(parameters.Modulus));
            writer.WriteString("exponent", Convert.ToBase64String(parameters.Exponent));
            writer.WriteString("d", Convert.ToBase64String(parameters.D));
            writer.WriteString("p", Convert.ToBase64String(parameters.P));
            writer.WriteString("q", Convert.ToBase64String(parameters.Q));
            writer.WriteString("dp", Convert.ToBase64String(parameters.DP));
            writer.WriteString("dq", Convert.ToBase64String(parameters.DQ));
            writer.WriteString("inverseQ", Convert.ToBase64String(parameters.InverseQ));
            writer.WriteEndObject();
            writer.WriteEndObject();
        }

        return buffer.ToArray();
    }

    private static void WriteHeader(Utf8JsonWriter writer, string format)
    {
        writer.WriteNumber("version", CurrentVersion);
        writer.WriteString("algorithm", Algorithm);
        writer.WriteString("format", format);
    }

    /// <summary>
    /// Exports the base64 X.509 SubjectPublicKeyInfo of <paramref name="rsa"/>. This is the exact
    /// representation <c>crypto.subtle.importKey('spki', ...)</c> expects in the browser.
    /// </summary>
    public static string ExportPublicKey(RSA rsa)
#if NET
        => Convert.ToBase64String(rsa.ExportSubjectPublicKeyInfo());
#else
        => Convert.ToBase64String(SubjectPublicKeyInfoWriter.Write(rsa.ExportParameters(includePrivateParameters: false)));
#endif

    /// <summary>
    /// Derives the base64 SubjectPublicKeyInfo that corresponds to a private key so that a key file
    /// pair can be checked for consistency without trusting the recorded value.
    /// </summary>
    public static bool TryDerivePublicKey(RSAParameters parameters, out string publicKey)
    {
        try
        {
            using var rsa = RSA.Create();
            rsa.ImportParameters(parameters);
            publicKey = ExportPublicKey(rsa);
            return true;
        }
        catch (CryptographicException)
        {
            publicKey = null;
            return false;
        }
    }

    public static (string PublicKey, RSAParameters Parameters) Create()
    {
        // The key size has to be requested at creation time: on .NET Framework the provider creates
        // the key eagerly and assigning KeySize afterwards would not regenerate it.
#if NET
        using var rsa = RSA.Create(KeySizeInBits);
#else
        using var rsa = new RSACryptoServiceProvider(KeySizeInBits);
#endif
        return (ExportPublicKey(rsa), rsa.ExportParameters(includePrivateParameters: true));
    }

#if !NET
    /// <summary>
    /// Minimal DER writer for the X.509 SubjectPublicKeyInfo of an RSA public key. .NET Framework
    /// has no <c>ExportSubjectPublicKeyInfo</c>, and the value has to be byte for byte identical to
    /// the one .NET produces because <c>dotnet watch</c> validates the two against each other.
    /// </summary>
    private static class SubjectPublicKeyInfoWriter
    {
        private static readonly byte[] RsaEncryptionOid = [0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01];

        public static byte[] Write(RSAParameters parameters)
        {
            if (parameters.Modulus == null || parameters.Exponent == null)
            {
                throw new CryptographicException("The RSA parameters do not contain public key information.");
            }

            // RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER }
            using var rsaPublicKey = new MemoryStream();
            WriteInteger(rsaPublicKey, parameters.Modulus);
            WriteInteger(rsaPublicKey, parameters.Exponent);
            var rsaPublicKeySequence = WrapSequence(rsaPublicKey.ToArray());

            // AlgorithmIdentifier ::= SEQUENCE { algorithm OBJECT IDENTIFIER, parameters NULL }
            using var algorithm = new MemoryStream();
            WriteTagged(algorithm, 0x06, RsaEncryptionOid);
            WriteTagged(algorithm, 0x05, []);
            var algorithmSequence = WrapSequence(algorithm.ToArray());

            // BIT STRING with zero unused bits wrapping the RSAPublicKey.
            var bitStringContent = new byte[rsaPublicKeySequence.Length + 1];
            bitStringContent[0] = 0x00;
            Array.Copy(rsaPublicKeySequence, 0, bitStringContent, 1, rsaPublicKeySequence.Length);

            using var subjectPublicKeyInfo = new MemoryStream();
            subjectPublicKeyInfo.Write(algorithmSequence, 0, algorithmSequence.Length);
            WriteTagged(subjectPublicKeyInfo, 0x03, bitStringContent);

            return WrapSequence(subjectPublicKeyInfo.ToArray());
        }

        private static byte[] WrapSequence(byte[] content)
        {
            using var stream = new MemoryStream();
            WriteTagged(stream, 0x30, content);
            return stream.ToArray();
        }

        private static void WriteTagged(Stream stream, byte tag, byte[] content)
        {
            stream.WriteByte(tag);
            WriteLength(stream, content.Length);
            stream.Write(content, 0, content.Length);
        }

        private static void WriteInteger(Stream stream, byte[] value)
        {
            var offset = 0;
            while (offset < value.Length - 1 && value[offset] == 0)
            {
                offset++;
            }

            var length = value.Length - offset;
            var needsPadding = value[offset] > 0x7f;

            stream.WriteByte(0x02);
            WriteLength(stream, needsPadding ? length + 1 : length);
            if (needsPadding)
            {
                stream.WriteByte(0x00);
            }

            stream.Write(value, offset, length);
        }

        private static void WriteLength(Stream stream, int length)
        {
            if (length < 0x80)
            {
                stream.WriteByte((byte)length);
                return;
            }

            var bytesRequired = 0;
            for (var remaining = length; remaining > 0; remaining >>= 8)
            {
                bytesRequired++;
            }

            stream.WriteByte((byte)(bytesRequired | 0x80));
            for (var i = bytesRequired - 1; i >= 0; i--)
            {
                stream.WriteByte((byte)((length >> (8 * i)) & 0xff));
            }
        }
    }
#endif
}