Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/Aspire.Cli/KnownFeatures.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ internal static class KnownFeatures
public static string ExperimentalPolyglotJava => "experimentalPolyglot:java";
public static string ExperimentalPolyglotGo => "experimentalPolyglot:go";
public static string ExperimentalPolyglotPython => "experimentalPolyglot:python";
public static string NuGetSignatureVerificationEnabled => "nugetSignatureVerificationEnabled";

private static readonly Dictionary<string, FeatureMetadata> s_featureMetadata = new()
{
Expand Down Expand Up @@ -80,7 +81,12 @@ internal static class KnownFeatures
[ExperimentalPolyglotPython] = new(
ExperimentalPolyglotPython,
"Enable or disable experimental Python language support for polyglot Aspire applications",
DefaultValue: false)
DefaultValue: false),

[NuGetSignatureVerificationEnabled] = new(
NuGetSignatureVerificationEnabled,
"Enable or disable defaulting the DOTNET_NUGET_SIGNATURE_VERIFICATION environment variable for spawned processes",
DefaultValue: true)
};

/// <summary>
Expand Down
16 changes: 14 additions & 2 deletions src/Aspire.Cli/NuGet/BundleNuGetService.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Cli.Configuration;
using Aspire.Cli.Layout;
using Microsoft.Extensions.Logging;

Expand Down Expand Up @@ -38,16 +39,22 @@ internal sealed class BundleNuGetService : INuGetService
{
private readonly ILayoutDiscovery _layoutDiscovery;
private readonly LayoutProcessRunner _layoutProcessRunner;
private readonly IFeatures _features;
private readonly CliExecutionContext _executionContext;
private readonly ILogger<BundleNuGetService> _logger;
private readonly string _cacheDirectory;

public BundleNuGetService(
ILayoutDiscovery layoutDiscovery,
LayoutProcessRunner layoutProcessRunner,
IFeatures features,
CliExecutionContext executionContext,
ILogger<BundleNuGetService> logger)
{
_layoutDiscovery = layoutDiscovery;
_layoutProcessRunner = layoutProcessRunner;
_features = features;
_executionContext = executionContext;
_logger = logger;
_cacheDirectory = GetCacheDirectory();
}
Expand Down Expand Up @@ -142,12 +149,16 @@ public async Task<string> RestorePackagesAsync(
_logger.LogDebug("aspire-managed path: {ManagedPath}", managedPath);
_logger.LogDebug("NuGet restore args: {Args}", string.Join(" ", restoreArgs));

var environmentVariables = new Dictionary<string, string>();
NuGetSignatureVerificationEnabler.Apply(environmentVariables, _features, _executionContext);

var (exitCode, output, error) = await _layoutProcessRunner.RunAsync(
managedPath,
restoreArgs,
environmentVariables: environmentVariables,
ct: ct);

// Log stderr output (verbose info from NuGetHelper)
// Log stderr at debug level for diagnostics
if (!string.IsNullOrWhiteSpace(error))
{
_logger.LogDebug("NuGetHelper restore stderr: {Error}", error);
Expand Down Expand Up @@ -190,9 +201,10 @@ public async Task<string> RestorePackagesAsync(
(exitCode, output, error) = await _layoutProcessRunner.RunAsync(
managedPath,
layoutArgs,
environmentVariables: environmentVariables,
ct: ct);

// Log stderr output (verbose info from NuGetHelper)
// Log stderr at debug level for diagnostics
if (!string.IsNullOrWhiteSpace(error))
{
_logger.LogDebug("NuGetHelper layout stderr: {Error}", error);
Expand Down
41 changes: 41 additions & 0 deletions src/Aspire.Cli/NuGet/NuGetSignatureVerificationEnabler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Cli.Configuration;

namespace Aspire.Cli.NuGet;

/// <summary>
/// Enables NuGet signature verification when spawning aspire-managed processes.
/// Mirrors the .NET SDK's NuGetSignatureVerificationEnabler behavior.
/// </summary>
internal static class NuGetSignatureVerificationEnabler
{
internal const string DotNetNuGetSignatureVerification = "DOTNET_NUGET_SIGNATURE_VERIFICATION";

/// <summary>
/// Applies NuGet signature verification environment variables to the given dictionary.
/// On Linux, sets DOTNET_NUGET_SIGNATURE_VERIFICATION to "true" unless the user
/// has explicitly set it to "false". The behavior can be disabled via the
/// <see cref="KnownFeatures.NuGetSignatureVerificationEnabled"/> feature flag.
/// </summary>
public static void Apply(Dictionary<string, string> environmentVariables, IFeatures features, CliExecutionContext executionContext)
{
if (!OperatingSystem.IsLinux() ||
!features.IsFeatureEnabled(
KnownFeatures.NuGetSignatureVerificationEnabled,
KnownFeatures.GetFeatureMetadata(KnownFeatures.NuGetSignatureVerificationEnabled)!.DefaultValue))
{
return;
}

var value = executionContext.GetEnvironmentVariable(DotNetNuGetSignatureVerification);

// If the user explicitly set it to "false", respect that
var effectiveValue = bool.TryParse(value, out var boolValue) && !boolValue
? bool.FalseString
: bool.TrueString;

environmentVariables[DotNetNuGetSignatureVerification] = effectiveValue;
}
}
9 changes: 9 additions & 0 deletions src/Aspire.Managed/Aspire.Managed.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,17 @@

<!-- Self-contained single-file publish settings (applied only during publish via Bundle.proj) -->
<PublishSingleFile>true</PublishSingleFile>

<!-- Locate the SDK's trustedroots directory for NuGet signature verification certificates -->
<_SdkTrustedRootsDir>$([System.IO.Path]::Combine($([System.IO.Path]::GetDirectoryName($(BundledRuntimeIdentifierGraphFile))), 'trustedroots'))</_SdkTrustedRootsDir>
</PropertyGroup>

<!-- Embed NuGet trusted root certificates from the .NET SDK for signature verification on Linux -->
<ItemGroup>
<EmbeddedResource Include="$(_SdkTrustedRootsDir)\*.pem" />
<EmbeddedResource Update="$(_SdkTrustedRootsDir)\*.pem" LogicalName="%(Filename)%(Extension)" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Aspire.Dashboard\Aspire.Dashboard.csproj" />
<ProjectReference Include="..\Aspire.Hosting.RemoteHost\Aspire.Hosting.RemoteHost.csproj" />
Expand Down
4 changes: 4 additions & 0 deletions src/Aspire.Managed/NuGet/Commands/RestoreCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,10 @@ private static async Task<int> ExecuteRestoreAsync(
MachineWideSettings = machineWideSettings,
};

// Initialize NuGet's trust store with embedded trusted root certificates
// so that package signature verification works on Linux.
TrustedRootsHelper.InitializeTrustStore();

var results = await RestoreRunner.RunAsync(restoreArgs).ConfigureAwait(false);
var summary = results.Count > 0 ? results[0] : null;

Expand Down
230 changes: 230 additions & 0 deletions src/Aspire.Managed/NuGet/TrustedRootsHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

#pragma warning disable CA1852 // DispatchProxy classes can't be sealed

using System.Reflection;
using System.Security.Cryptography.X509Certificates;
using NuGet.Packaging.Signing;

namespace Aspire.Managed.NuGet;

/// <summary>
/// Initializes NuGet's X509 trust store from embedded trusted root PEM certificates
/// for package signature verification on Linux, without writing to disk.
/// </summary>
internal static class TrustedRootsHelper
{
private static bool s_initialized;

/// <summary>
/// Initializes the NuGet trust store with embedded trusted root certificates.
/// On Linux, when DOTNET_NUGET_SIGNATURE_VERIFICATION is set to "true", NuGet requires
/// certificate bundles for signature verification. The .NET SDK ships these as PEM files
/// in its trustedroots directory, but aspire-managed is a single-file app without access
/// to the SDK's directory structure. This method loads embedded PEM resources in memory
/// and uses DispatchProxy to create IX509ChainFactory implementations that NuGet's trust
/// store can use.
/// </summary>
/// <remarks>
/// TODO: Remove this once NuGet supports a public API for configuring the trust store,
/// or when it supports single-file apps. See https://github.com/dotnet/aspire/issues/15282.
/// </remarks>
public static void InitializeTrustStore()
{
Comment thread
eerhardt marked this conversation as resolved.
if (s_initialized)
{
return;
}

if (!OperatingSystem.IsLinux())
{
// On Windows, NuGet uses the system certificate store directly.
// On macOS, matching .NET SDK behavior which only enables this on Linux.
return;
}

var envValue = Environment.GetEnvironmentVariable("DOTNET_NUGET_SIGNATURE_VERIFICATION");
Comment thread
eerhardt marked this conversation as resolved.
if (!(bool.TryParse(envValue, out var enabled) && enabled))
{
// If DOTNET_NUGET_SIGNATURE_VERIFICATION is not set to "true", NuGet won't
// perform signature verification on Linux, so there's no need to initialize
// the trust store.
return;
}

try
{
InitializeTrustStoreFromEmbeddedResources();
s_initialized = true;
}
catch (Exception ex)
{
// Log but don't fail the restore. If trust store initialization fails,
// NuGet may still work if signature verification is not required or if
// the system has its own certificate bundles.
Console.Error.WriteLine($"WARNING: Failed to initialize NuGet trust store from embedded certificates: {ex}");
}
}

private static void InitializeTrustStoreFromEmbeddedResources()
{
var nugetPackagingAssembly = typeof(X509TrustStore).Assembly;

// Resolve internal NuGet types needed for DispatchProxy creation
var chainFactoryInterfaceType = nugetPackagingAssembly.GetType("NuGet.Packaging.Signing.IX509ChainFactory");
var chainInterfaceType = nugetPackagingAssembly.GetType("NuGet.Packaging.Signing.IX509Chain");
Comment on lines +75 to +76
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😬

Have we talked with NuGet team about making these public?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need NuGet/NuGet.Client#7197 in order to not use reflection. It hasn't been released yet.

When it is, we can write the files to a folder next to us, and won't need this reflection.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there an issue to track fixing the reflection?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


if (chainFactoryInterfaceType is null || chainInterfaceType is null)
{
Console.Error.WriteLine("WARNING: Could not find IX509ChainFactory or IX509Chain types in NuGet.Packaging.");
return;
}

// Set up code signing trust store
SetTrustStoreFactory(
"SetCodeSigningX509ChainFactory",
"codesignctl.pem",
chainFactoryInterfaceType,
chainInterfaceType);

// Set up timestamping trust store
SetTrustStoreFactory(
"SetTimestampingX509ChainFactory",
"timestampctl.pem",
chainFactoryInterfaceType,
chainInterfaceType);
}

private static void SetTrustStoreFactory(
string setterMethodName,
string resourceName,
Type chainFactoryInterfaceType,
Type chainInterfaceType)
{
var certificates = LoadCertificatesFromResource(resourceName);
if (certificates is null || certificates.Count == 0)
{
Console.Error.WriteLine($"WARNING: No certificates loaded from embedded resource: {resourceName}");
return;
}

// Create IX509ChainFactory proxy via DispatchProxy
var factory = ChainFactoryDispatchProxy.CreateFactory(
chainFactoryInterfaceType, chainInterfaceType, certificates);

// Call the setter on X509TrustStore to register the factory
var setter = typeof(X509TrustStore).GetMethod(
setterMethodName,
BindingFlags.NonPublic | BindingFlags.Static);

if (setter is null)
{
Console.Error.WriteLine($"WARNING: Could not find {setterMethodName} on X509TrustStore.");
return;
}

setter.Invoke(null, [factory]);
}

private static X509Certificate2Collection? LoadCertificatesFromResource(string resourceName)
{
using var stream = typeof(TrustedRootsHelper).Assembly.GetManifestResourceStream(resourceName);
if (stream is null)
{
return null;
}

using var reader = new StreamReader(stream);
var pemContents = reader.ReadToEnd();

var certificates = new X509Certificate2Collection();
certificates.ImportFromPem(pemContents);
return certificates;
}
}

/// <summary>
/// DispatchProxy that implements NuGet's internal IX509ChainFactory interface.
/// Creates X509Chain instances configured with custom root trust using embedded certificates.
/// </summary>
internal class ChainFactoryDispatchProxy : DispatchProxy
{
private X509Certificate2Collection _certificates = [];
private Type _chainInterfaceType = null!;

internal static object CreateFactory(
Type chainFactoryInterfaceType,
Type chainInterfaceType,
X509Certificate2Collection certificates)
{
var proxy = (ChainFactoryDispatchProxy)Create(chainFactoryInterfaceType, typeof(ChainFactoryDispatchProxy));

proxy._certificates = certificates;
proxy._chainInterfaceType = chainInterfaceType;
return proxy;
}

protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
{
// IX509ChainFactory has a single method: IX509Chain Create()
if (targetMethod?.Name == "Create")
{
return CreateChain();
}

throw new NotSupportedException($"Method '{targetMethod?.Name}' is not supported on IX509ChainFactory proxy. This may indicate a NuGet.Packaging version mismatch — the library may have added new interface members.");
}

private object CreateChain()
{
// Create an IX509Chain proxy that wraps an X509Chain with custom root trust
return ChainDispatchProxy.CreateChain(_chainInterfaceType, _certificates);
}
}

/// <summary>
/// DispatchProxy that implements NuGet's internal IX509Chain interface.
/// Wraps an X509Chain configured with CustomRootTrust mode.
/// </summary>
internal class ChainDispatchProxy : DispatchProxy
{
private readonly X509Chain _chain = new();

internal static object CreateChain(
Type chainInterfaceType,
X509Certificate2Collection certificates)
{
var proxy = (ChainDispatchProxy)Create(chainInterfaceType, typeof(ChainDispatchProxy));

proxy._chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
proxy._chain.ChainPolicy.CustomTrustStore.AddRange(certificates);
return proxy;
}

protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
{
return targetMethod?.Name switch
Comment thread
eerhardt marked this conversation as resolved.
{
"Build" => Build((X509Certificate2)args![0]!),
"Dispose" => Dispose(),
"get_ChainElements" => _chain.ChainElements,
"get_ChainPolicy" => _chain.ChainPolicy,
"get_ChainStatus" => _chain.ChainStatus,
"get_PrivateReference" => _chain,
"get_AdditionalContext" => (global::NuGet.Common.ILogMessage?)null,
_ => throw new NotSupportedException($"Method '{targetMethod?.Name}' is not supported on IX509Chain proxy. This may indicate a NuGet.Packaging version mismatch — the library may have added new interface members.")
};
}

private bool Build(X509Certificate2 certificate)
{
return _chain.Build(certificate);
}

private object? Dispose()
{
_chain.Dispose();
return null;
}
}
Loading
Loading