Skip to content

Commit 74fa240

Browse files
eerhardtCopilot
andcommitted
Enable NuGet signature verification for aspire-managed on Linux
Set DOTNET_NUGET_SIGNATURE_VERIFICATION=true when spawning aspire-managed processes on Linux, mirroring the .NET SDK's NuGetSignatureVerificationEnabler behavior. Embed the SDK's trusted root PEM certificates (codesignctl.pem, timestampctl.pem) as resources in Aspire.Managed, and initialize NuGet's X509TrustStore via reflection before running restore operations. This is needed because aspire-managed is a single-file app where NuGet's fallback certificate bundle discovery (assembly-relative path) doesn't work. Fixes #15282 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent db444f8 commit 74fa240

File tree

8 files changed

+310
-7
lines changed

8 files changed

+310
-7
lines changed

src/Aspire.Cli/KnownFeatures.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ internal static class KnownFeatures
2929
public static string ExperimentalPolyglotJava => "experimentalPolyglot:java";
3030
public static string ExperimentalPolyglotGo => "experimentalPolyglot:go";
3131
public static string ExperimentalPolyglotPython => "experimentalPolyglot:python";
32+
public static string NuGetSignatureVerificationEnabled => "nugetSignatureVerificationEnabled";
3233

3334
private static readonly Dictionary<string, FeatureMetadata> s_featureMetadata = new()
3435
{
@@ -80,7 +81,12 @@ internal static class KnownFeatures
8081
[ExperimentalPolyglotPython] = new(
8182
ExperimentalPolyglotPython,
8283
"Enable or disable experimental Python language support for polyglot Aspire applications",
83-
DefaultValue: false)
84+
DefaultValue: false),
85+
86+
[NuGetSignatureVerificationEnabled] = new(
87+
NuGetSignatureVerificationEnabled,
88+
"Enable or disable defaulting the DOTNET_NUGET_SIGNATURE_VERIFICATION environment variable for spawned processes",
89+
DefaultValue: true)
8490
};
8591

8692
/// <summary>

src/Aspire.Cli/NuGet/BundleNuGetService.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using Aspire.Cli.Configuration;
45
using Aspire.Cli.Layout;
56
using Microsoft.Extensions.Logging;
67

@@ -38,16 +39,19 @@ internal sealed class BundleNuGetService : INuGetService
3839
{
3940
private readonly ILayoutDiscovery _layoutDiscovery;
4041
private readonly LayoutProcessRunner _layoutProcessRunner;
42+
private readonly IFeatures _features;
4143
private readonly ILogger<BundleNuGetService> _logger;
4244
private readonly string _cacheDirectory;
4345

4446
public BundleNuGetService(
4547
ILayoutDiscovery layoutDiscovery,
4648
LayoutProcessRunner layoutProcessRunner,
49+
IFeatures features,
4750
ILogger<BundleNuGetService> logger)
4851
{
4952
_layoutDiscovery = layoutDiscovery;
5053
_layoutProcessRunner = layoutProcessRunner;
54+
_features = features;
5155
_logger = logger;
5256
_cacheDirectory = GetCacheDirectory();
5357
}
@@ -142,12 +146,16 @@ public async Task<string> RestorePackagesAsync(
142146
_logger.LogDebug("aspire-managed path: {ManagedPath}", managedPath);
143147
_logger.LogDebug("NuGet restore args: {Args}", string.Join(" ", restoreArgs));
144148

149+
var environmentVariables = new Dictionary<string, string>();
150+
NuGetSignatureVerificationEnabler.Apply(environmentVariables, _features);
151+
145152
var (exitCode, output, error) = await _layoutProcessRunner.RunAsync(
146153
managedPath,
147154
restoreArgs,
155+
environmentVariables: environmentVariables,
148156
ct: ct);
149157

150-
// Log stderr output (verbose info from NuGetHelper)
158+
// Log stderr at debug level for diagnostics
151159
if (!string.IsNullOrWhiteSpace(error))
152160
{
153161
_logger.LogDebug("NuGetHelper restore stderr: {Error}", error);
@@ -190,9 +198,10 @@ public async Task<string> RestorePackagesAsync(
190198
(exitCode, output, error) = await _layoutProcessRunner.RunAsync(
191199
managedPath,
192200
layoutArgs,
201+
environmentVariables: environmentVariables,
193202
ct: ct);
194203

195-
// Log stderr output (verbose info from NuGetHelper)
204+
// Log stderr at debug level for diagnostics
196205
if (!string.IsNullOrWhiteSpace(error))
197206
{
198207
_logger.LogDebug("NuGetHelper layout stderr: {Error}", error);
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Aspire.Cli.Configuration;
5+
6+
namespace Aspire.Cli.NuGet;
7+
8+
/// <summary>
9+
/// Enables NuGet signature verification when spawning aspire-managed processes.
10+
/// Mirrors the .NET SDK's NuGetSignatureVerificationEnabler behavior.
11+
/// </summary>
12+
internal static class NuGetSignatureVerificationEnabler
13+
{
14+
internal const string DotNetNuGetSignatureVerification = "DOTNET_NUGET_SIGNATURE_VERIFICATION";
15+
16+
/// <summary>
17+
/// Applies NuGet signature verification environment variables to the given dictionary.
18+
/// On Linux, sets DOTNET_NUGET_SIGNATURE_VERIFICATION to "true" unless the user
19+
/// has explicitly set it to "false". The behavior can be disabled via the
20+
/// <see cref="KnownFeatures.NuGetSignatureVerificationEnabled"/> feature flag.
21+
/// </summary>
22+
public static void Apply(Dictionary<string, string> environmentVariables, IFeatures features)
23+
{
24+
if (!OperatingSystem.IsLinux() ||
25+
!features.IsFeatureEnabled(
26+
KnownFeatures.NuGetSignatureVerificationEnabled,
27+
KnownFeatures.GetFeatureMetadata(KnownFeatures.NuGetSignatureVerificationEnabled)!.DefaultValue))
28+
{
29+
return;
30+
}
31+
32+
var value = Environment.GetEnvironmentVariable(DotNetNuGetSignatureVerification);
33+
34+
// If the user explicitly set it to "false", respect that
35+
var effectiveValue = string.Equals(bool.FalseString, value, StringComparison.OrdinalIgnoreCase)
36+
? bool.FalseString
37+
: bool.TrueString;
38+
39+
environmentVariables[DotNetNuGetSignatureVerification] = effectiveValue;
40+
}
41+
}

src/Aspire.Managed/Aspire.Managed.csproj

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,17 @@
1919

2020
<!-- Self-contained single-file publish settings (applied only during publish via Bundle.proj) -->
2121
<PublishSingleFile>true</PublishSingleFile>
22+
23+
<!-- Locate the SDK's trustedroots directory for NuGet signature verification certificates -->
24+
<_SdkTrustedRootsDir>$([System.IO.Path]::Combine($([System.IO.Path]::GetDirectoryName($(BundledRuntimeIdentifierGraphFile))), 'trustedroots'))</_SdkTrustedRootsDir>
2225
</PropertyGroup>
2326

27+
<!-- Embed NuGet trusted root certificates from the .NET SDK for signature verification on Linux -->
28+
<ItemGroup>
29+
<EmbeddedResource Include="$(_SdkTrustedRootsDir)\*.pem" />
30+
<EmbeddedResource Update="$(_SdkTrustedRootsDir)\*.pem" LogicalName="%(Filename)%(Extension)" />
31+
</ItemGroup>
32+
2433
<ItemGroup>
2534
<ProjectReference Include="..\Aspire.Dashboard\Aspire.Dashboard.csproj" />
2635
<ProjectReference Include="..\Aspire.Hosting.RemoteHost\Aspire.Hosting.RemoteHost.csproj" />

src/Aspire.Managed/NuGet/Commands/RestoreCommand.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,10 @@ private static async Task<int> ExecuteRestoreAsync(
194194
MachineWideSettings = machineWideSettings,
195195
};
196196

197+
// Initialize NuGet's trust store with embedded trusted root certificates
198+
// so that package signature verification works on Linux.
199+
TrustedRootsHelper.InitializeTrustStore(logger);
200+
197201
var results = await RestoreRunner.RunAsync(restoreArgs).ConfigureAwait(false);
198202
var summary = results.Count > 0 ? results[0] : null;
199203

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
#pragma warning disable CA1852 // DispatchProxy classes can't be sealed
5+
6+
using System.Reflection;
7+
using System.Security.Cryptography.X509Certificates;
8+
using NuGet.Packaging.Signing;
9+
using INuGetLogger = NuGet.Common.ILogger;
10+
11+
namespace Aspire.Managed.NuGet;
12+
13+
/// <summary>
14+
/// Initializes NuGet's X509 trust store from embedded trusted root PEM certificates
15+
/// for package signature verification on Linux, without writing to disk.
16+
/// </summary>
17+
internal static class TrustedRootsHelper
18+
{
19+
/// <summary>
20+
/// Initializes the NuGet trust store with embedded trusted root certificates.
21+
/// On Linux, when DOTNET_NUGET_SIGNATURE_VERIFICATION is set to "true", NuGet requires
22+
/// certificate bundles for signature verification. The .NET SDK ships these as PEM files
23+
/// in its trustedroots directory, but aspire-managed is a single-file app without access
24+
/// to the SDK's directory structure. This method loads embedded PEM resources in memory
25+
/// and uses DispatchProxy to create IX509ChainFactory implementations that NuGet's trust
26+
/// store can use.
27+
/// </summary>
28+
/// <remarks>
29+
/// TODO: Remove this once NuGet supports a public API for configuring the trust store,
30+
/// or when it supports single-file apps. See https://github.com/dotnet/aspire/issues/15282.
31+
/// </remarks>
32+
public static void InitializeTrustStore(INuGetLogger logger)
33+
{
34+
if (!OperatingSystem.IsLinux())
35+
{
36+
// On Windows, NuGet uses the system certificate store directly.
37+
// On macOS, matching .NET SDK behavior which only enables this on Linux.
38+
return;
39+
}
40+
41+
var envValue = Environment.GetEnvironmentVariable("DOTNET_NUGET_SIGNATURE_VERIFICATION");
42+
if (!string.Equals(bool.TrueString, envValue, StringComparison.OrdinalIgnoreCase))
43+
{
44+
// If DOTNET_NUGET_SIGNATURE_VERIFICATION is not set to "true", NuGet won't
45+
// perform signature verification on Linux, so there's no need to initialize
46+
// the trust store.
47+
return;
48+
}
49+
50+
try
51+
{
52+
InitializeTrustStoreFromEmbeddedResources(logger);
53+
}
54+
catch (Exception ex)
55+
{
56+
// Log but don't fail the restore. If trust store initialization fails,
57+
// NuGet may still work if signature verification is not required or if
58+
// the system has its own certificate bundles.
59+
logger.LogWarning($"Failed to initialize NuGet trust store from embedded certificates: {ex.Message}");
60+
}
61+
}
62+
63+
private static void InitializeTrustStoreFromEmbeddedResources(INuGetLogger logger)
64+
{
65+
var nugetPackagingAssembly = typeof(X509TrustStore).Assembly;
66+
67+
// Resolve internal NuGet types needed for DispatchProxy creation
68+
var chainFactoryInterfaceType = nugetPackagingAssembly.GetType("NuGet.Packaging.Signing.IX509ChainFactory");
69+
var chainInterfaceType = nugetPackagingAssembly.GetType("NuGet.Packaging.Signing.IX509Chain");
70+
71+
if (chainFactoryInterfaceType is null || chainInterfaceType is null)
72+
{
73+
logger.LogWarning("Could not find IX509ChainFactory or IX509Chain types in NuGet.Packaging.");
74+
return;
75+
}
76+
77+
// Set up code signing trust store
78+
SetTrustStoreFactory(
79+
"SetCodeSigningX509ChainFactory",
80+
"codesignctl.pem",
81+
chainFactoryInterfaceType,
82+
chainInterfaceType,
83+
logger);
84+
85+
// Set up timestamping trust store
86+
SetTrustStoreFactory(
87+
"SetTimestampingX509ChainFactory",
88+
"timestampctl.pem",
89+
chainFactoryInterfaceType,
90+
chainInterfaceType,
91+
logger);
92+
}
93+
94+
private static void SetTrustStoreFactory(
95+
string setterMethodName,
96+
string resourceName,
97+
Type chainFactoryInterfaceType,
98+
Type chainInterfaceType,
99+
INuGetLogger logger)
100+
{
101+
var certificates = LoadCertificatesFromResource(resourceName);
102+
if (certificates is null || certificates.Count == 0)
103+
{
104+
logger.LogWarning($"No certificates loaded from embedded resource: {resourceName}");
105+
return;
106+
}
107+
108+
// Create IX509ChainFactory proxy via DispatchProxy
109+
var factory = ChainFactoryDispatchProxy.CreateFactory(
110+
chainFactoryInterfaceType, chainInterfaceType, certificates);
111+
112+
// Call the setter on X509TrustStore to register the factory
113+
var setter = typeof(X509TrustStore).GetMethod(
114+
setterMethodName,
115+
BindingFlags.NonPublic | BindingFlags.Static);
116+
117+
if (setter is null)
118+
{
119+
logger.LogWarning($"Could not find {setterMethodName} on X509TrustStore.");
120+
return;
121+
}
122+
123+
setter.Invoke(null, [factory]);
124+
logger.LogInformation($"Initialized NuGet trust store from embedded resource: {resourceName} ({certificates.Count} certificates)");
125+
}
126+
127+
private static X509Certificate2Collection? LoadCertificatesFromResource(string resourceName)
128+
{
129+
using var stream = typeof(TrustedRootsHelper).Assembly.GetManifestResourceStream(resourceName);
130+
if (stream is null)
131+
{
132+
return null;
133+
}
134+
135+
using var reader = new StreamReader(stream);
136+
var pemContents = reader.ReadToEnd();
137+
138+
var certificates = new X509Certificate2Collection();
139+
certificates.ImportFromPem(pemContents);
140+
return certificates;
141+
}
142+
}
143+
144+
/// <summary>
145+
/// DispatchProxy that implements NuGet's internal IX509ChainFactory interface.
146+
/// Creates X509Chain instances configured with custom root trust using embedded certificates.
147+
/// </summary>
148+
internal class ChainFactoryDispatchProxy : DispatchProxy
149+
{
150+
private X509Certificate2Collection _certificates = [];
151+
private Type _chainInterfaceType = null!;
152+
153+
internal static object CreateFactory(
154+
Type chainFactoryInterfaceType,
155+
Type chainInterfaceType,
156+
X509Certificate2Collection certificates)
157+
{
158+
var proxy = (ChainFactoryDispatchProxy)Create(chainFactoryInterfaceType, typeof(ChainFactoryDispatchProxy));
159+
160+
proxy._certificates = certificates;
161+
proxy._chainInterfaceType = chainInterfaceType;
162+
return proxy;
163+
}
164+
165+
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
166+
{
167+
// IX509ChainFactory has a single method: IX509Chain Create()
168+
if (targetMethod?.Name == "Create")
169+
{
170+
return CreateChain();
171+
}
172+
173+
throw new NotSupportedException($"Method {targetMethod?.Name} is not supported.");
174+
}
175+
176+
private object CreateChain()
177+
{
178+
// Create an IX509Chain proxy that wraps an X509Chain with custom root trust
179+
return ChainDispatchProxy.CreateChain(_chainInterfaceType, _certificates);
180+
}
181+
}
182+
183+
/// <summary>
184+
/// DispatchProxy that implements NuGet's internal IX509Chain interface.
185+
/// Wraps an X509Chain configured with CustomRootTrust mode.
186+
/// </summary>
187+
internal class ChainDispatchProxy : DispatchProxy
188+
{
189+
private readonly X509Chain _chain = new();
190+
191+
internal static object CreateChain(
192+
Type chainInterfaceType,
193+
X509Certificate2Collection certificates)
194+
{
195+
var proxy = (ChainDispatchProxy)Create(chainInterfaceType, typeof(ChainDispatchProxy));
196+
197+
proxy._chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
198+
proxy._chain.ChainPolicy.CustomTrustStore.AddRange(certificates);
199+
return proxy;
200+
}
201+
202+
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
203+
{
204+
return targetMethod?.Name switch
205+
{
206+
"Build" => Build((X509Certificate2)args![0]!),
207+
"Dispose" => Dispose(),
208+
"get_ChainElements" => _chain.ChainElements,
209+
"get_ChainPolicy" => _chain.ChainPolicy,
210+
"get_ChainStatus" => _chain.ChainStatus,
211+
"get_PrivateReference" => _chain,
212+
"get_AdditionalContext" => (global::NuGet.Common.ILogMessage?)null,
213+
_ => throw new NotSupportedException($"Method {targetMethod?.Name} is not supported.")
214+
};
215+
}
216+
217+
private bool Build(X509Certificate2 certificate)
218+
{
219+
return _chain.Build(certificate);
220+
}
221+
222+
private object? Dispose()
223+
{
224+
_chain.Dispose();
225+
return null;
226+
}
227+
}

tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ public void Constructor_UsesUserAspireDirectoryForWorkingDirectory()
155155
{
156156
using var workspace = TemporaryWorkspace.Create(outputHelper);
157157

158-
var nugetService = new BundleNuGetService(new NullLayoutDiscovery(), new LayoutProcessRunner(new TestProcessExecutionFactory()), Microsoft.Extensions.Logging.Abstractions.NullLogger<BundleNuGetService>.Instance);
158+
var nugetService = new BundleNuGetService(new NullLayoutDiscovery(), new LayoutProcessRunner(new TestProcessExecutionFactory()), new TestFeatures(), Microsoft.Extensions.Logging.Abstractions.NullLogger<BundleNuGetService>.Instance);
159159
var server = new PrebuiltAppHostServer(
160160
workspace.WorkspaceRoot.FullName,
161161
"test.sock",
@@ -209,7 +209,7 @@ await File.WriteAllTextAsync(aspireConfigPath, """
209209
OnGetConfiguration = key => key == "channel" ? "pr-old" : null
210210
};
211211

212-
var nugetService = new BundleNuGetService(new NullLayoutDiscovery(), new LayoutProcessRunner(new TestProcessExecutionFactory()), Microsoft.Extensions.Logging.Abstractions.NullLogger<BundleNuGetService>.Instance);
212+
var nugetService = new BundleNuGetService(new NullLayoutDiscovery(), new LayoutProcessRunner(new TestProcessExecutionFactory()), new TestFeatures(), Microsoft.Extensions.Logging.Abstractions.NullLogger<BundleNuGetService>.Instance);
213213
var server = new PrebuiltAppHostServer(
214214
workspace.WorkspaceRoot.FullName,
215215
"test.sock",

0 commit comments

Comments
 (0)