File: src\sdk\src\Dotnet.Watch\HotReloadClient\Web\BrowserToolsBuildOutputs.cs
Web Access
Project: Microsoft.DotNet.HotReload.Watch.csproj (Microsoft.DotNet.HotReload.Watch)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

#nullable enable

using System;
using System.IO;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.DotNet.HotReload;
using Microsoft.Extensions.Logging;

namespace Microsoft.DotNet.HotReload;

/// <summary>
/// The build outputs the Static Web Assets SDK produces for the <c>dotnet watch</c> browser tools,
/// and the only channel between the build and <c>dotnet watch</c> for them.
///
/// The direction of this channel is deliberate. The browser authenticates the provider with a key
/// the application pins at build time, so the key has to be created by the build; a key that the
/// watcher pushed into the build through a global property would let the provider authenticate
/// itself, and a global property could not be scoped to a project anyway. The build therefore owns
/// the key pair, and the watcher reads it back per project instance.
///
/// The paths are derived from evaluated MSBuild properties, rather than by searching the file system.
/// They must stay in sync with Microsoft.NET.Sdk.StaticWebAssets.HotReload.targets.
/// </summary>
internal sealed class BrowserToolsBuildOutputs
{
    /// <summary>
    /// Subdirectory of the project's intermediate output that holds the browser tools build outputs.
    /// </summary>
    public const string DirectoryName = "HotReload";

    public const string PublicKeyFileName = "browser-tools-key.public.json";
    public const string PrivateKeyFileName = "browser-tools-key.private.json";

    private const int SupportedKeyDocumentVersion = 1;
    private const string SupportedKeyAlgorithm = "RSA-OAEP-SHA256";
    private const string PublicKeyFormat = "SubjectPublicKeyInfo";
    private const string PrivateKeyFormat = "RSAParameters";

    private readonly string _publicKeyPath;
    private readonly string _privateKeyPath;

    private BrowserToolsBuildOutputs(string directory)
    {
        _publicKeyPath = Path.Combine(directory, PublicKeyFileName);
        _privateKeyPath = Path.Combine(directory, PrivateKeyFileName);
    }

    /// <summary>
    /// Returns the build outputs of the given project, or null when the project does
    /// not produce browser tools assets at all.
    ///
    /// Whether it does is decided by evaluated properties that mirror the condition on the target that produces them,
    /// so a project that opted out - or an SDK that has no browser tools initializer - is recognized without touching the file
    /// system.
    /// </summary>
    public static BrowserToolsBuildOutputs? FromProjectSettings(
        string configuration,
        string? intermediateOutputDirectory,
        bool? enableHotReloadInRuntimeConfigDevFile,
        string browserToolsAssetPrefix,
        bool staticWebAssetsEnabled,
        bool jsModulesEnabled)
    {
        var enableHotReload = enableHotReloadInRuntimeConfigDevFile ?? string.Equals(configuration, "Debug", StringComparison.OrdinalIgnoreCase);

        if (!enableHotReload ||
            browserToolsAssetPrefix is not { Length: > 0 } ||
            !staticWebAssetsEnabled ||
            !jsModulesEnabled ||
            intermediateOutputDirectory is null)
        {
            return null;
        }

        return new BrowserToolsBuildOutputs(Path.Combine(intermediateOutputDirectory, DirectoryName));
    }

    /// <summary>
    /// Creates the outputs for a directory directly, bypassing project evaluation. Only used by
    /// tests: production code always derives the directory from the project instance so that the
    /// paths cannot drift from what the build wrote.
    /// </summary>
    internal static BrowserToolsBuildOutputs CreateForTesting(string directory)
        => new(directory);

    /// <summary>
    /// Creates the provider's session key from the private half the build produced, after checking
    /// that it is the key the application pinned. Never logs key material.
    /// </summary>
    public SharedSecretProvider? TryCreateSessionKey(ILogger logger)
    {
        try
        {
            var pinnedPublicKey = ReadPinnedPublicKey();
            var parameters = ReadPrivateKey(out var recordedPublicKey);

            if (!string.Equals(pinnedPublicKey, recordedPublicKey, StringComparison.Ordinal))
            {
                throw Fail("the public and private key documents describe different keys");
            }

            SharedSecretProvider sessionKey;
            try
            {
                sessionKey = new SharedSecretProvider(parameters);
            }
            catch (CryptographicException)
            {
                throw Fail("the private key could not be imported");
            }

            // The recorded value is not trusted on its own: the key that the provider will actually use
            // has to be the key the application pinned, otherwise the browser rejects the provider.
            if (!string.Equals(sessionKey.GetPublicKey(), pinnedPublicKey, StringComparison.Ordinal))
            {
                sessionKey.Dispose();
                throw Fail("the private key does not match the public key the application pinned");
            }

            logger.Log(LogEvents.BrowserToolsUsingKeyFromBuild);
            return sessionKey;
        }
        catch (Exception e)
        {
            logger.LogError("Unable to load the browser tools session key. {Message}", e.Message);
            return null;
        }
    }

    private string ReadPinnedPublicKey()
    {
        using var document = ReadDocument(_publicKeyPath);
        ValidateHeader(document.RootElement, PublicKeyFormat, _publicKeyPath);
        return ReadRequiredBase64(document.RootElement, "publicKey", _publicKeyPath);
    }

    private RSAParameters ReadPrivateKey(out string recordedPublicKey)
    {
        using var document = ReadDocument(_privateKeyPath);
        var root = document.RootElement;
        ValidateHeader(root, PrivateKeyFormat, _privateKeyPath);
        recordedPublicKey = ReadRequiredBase64(root, "publicKey", _privateKeyPath);

        if (!root.TryGetProperty("parameters", out var parameters) || parameters.ValueKind != JsonValueKind.Object)
        {
            throw Fail($"'{Path.GetFileName(_privateKeyPath)}' does not contain the private key components");
        }

        return new RSAParameters
        {
            Modulus = ReadRequiredBase64Bytes(parameters, "modulus", _privateKeyPath),
            Exponent = ReadRequiredBase64Bytes(parameters, "exponent", _privateKeyPath),
            D = ReadRequiredBase64Bytes(parameters, "d", _privateKeyPath),
            P = ReadRequiredBase64Bytes(parameters, "p", _privateKeyPath),
            Q = ReadRequiredBase64Bytes(parameters, "q", _privateKeyPath),
            DP = ReadRequiredBase64Bytes(parameters, "dp", _privateKeyPath),
            DQ = ReadRequiredBase64Bytes(parameters, "dq", _privateKeyPath),
            InverseQ = ReadRequiredBase64Bytes(parameters, "inverseQ", _privateKeyPath),
        };
    }

    private JsonDocument ReadDocument(string path)
    {
        byte[] content;
        try
        {
            content = File.ReadAllBytes(path);
        }
        catch (FileNotFoundException)
        {
            throw Fail($"'{Path.GetFileName(path)}' was not produced by the build");
        }
        catch (DirectoryNotFoundException)
        {
            throw Fail($"'{Path.GetFileName(path)}' was not produced by the build");
        }
        catch (Exception e) when (e is IOException or UnauthorizedAccessException)
        {
            throw Fail($"'{Path.GetFileName(path)}' could not be read ({e.GetType().Name})");
        }

        try
        {
            return JsonDocument.Parse(content);
        }
        catch (JsonException)
        {
            throw Fail($"'{Path.GetFileName(path)}' is not a valid JSON document");
        }
    }

    private void ValidateHeader(JsonElement root, string expectedFormat, string path)
    {
        var name = Path.GetFileName(path);

        if (root.ValueKind != JsonValueKind.Object)
        {
            throw Fail($"'{name}' is not a JSON object");
        }

        if (!root.TryGetProperty("version", out var version) ||
            version.ValueKind != JsonValueKind.Number ||
            !version.TryGetInt32(out var versionValue) ||
            versionValue != SupportedKeyDocumentVersion)
        {
            throw Fail($"'{name}' does not have the supported document version {SupportedKeyDocumentVersion}");
        }

        if (!root.TryGetProperty("algorithm", out var algorithm) ||
            algorithm.ValueKind != JsonValueKind.String ||
            algorithm.GetString() != SupportedKeyAlgorithm)
        {
            throw Fail($"'{name}' does not use the supported key algorithm");
        }

        if (!root.TryGetProperty("format", out var format) ||
            format.ValueKind != JsonValueKind.String ||
            format.GetString() != expectedFormat)
        {
            throw Fail($"'{name}' does not use the expected key format '{expectedFormat}'");
        }
    }

    private string ReadRequiredBase64(JsonElement element, string propertyName, string path)
    {
        if (!element.TryGetProperty(propertyName, out var property) ||
            property.ValueKind != JsonValueKind.String ||
            property.GetString() is not { Length: > 0 } value)
        {
            throw Fail($"'{Path.GetFileName(path)}' does not contain '{propertyName}'");
        }

        try
        {
            _ = Convert.FromBase64String(value);
        }
        catch (FormatException)
        {
            throw Fail($"'{propertyName}' in '{Path.GetFileName(path)}' is not base64");
        }

        return value;
    }

    private byte[] ReadRequiredBase64Bytes(JsonElement element, string propertyName, string path)
        => Convert.FromBase64String(ReadRequiredBase64(element, propertyName, path));

    private Exception Fail(string reason)
        => new($"Unable to start the HotReload browser tools because {reason}. Rebuild the project to regenerate the browser tools build outputs.");
}