Skip to content

Commit b696ea1

Browse files
thenextmanclaude
andcommitted
feat(installer): install time certificate checks
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cfe3c5f commit b696ea1

16 files changed

Lines changed: 1620 additions & 281 deletions

package/WindowsManaged/Actions/CustomActions.cs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
using WixSharp;
2929
using static DevolutionsGateway.Actions.WinAPI;
3030
using File = System.IO.File;
31+
using StoreLocation = System.Security.Cryptography.X509Certificates.StoreLocation;
32+
using StoreName = System.Security.Cryptography.X509Certificates.StoreName;
3133

3234
namespace DevolutionsGateway.Actions
3335
{
@@ -1069,6 +1071,80 @@ public static ActionResult SetProgramDataDirectoryPermissions(Session session)
10691071
}
10701072
}
10711073

1074+
[CustomAction]
1075+
public static ActionResult SetCertificatePrivateKeyPermissions(Session session)
1076+
{
1077+
try
1078+
{
1079+
// Skip when the gateway will auto-generate a certificate — the selected system-store
1080+
// cert (if any) isn't actually being used in that case.
1081+
if (session.Get(GatewayProperties.configureWebApp) && session.Get(GatewayProperties.generateCertificate))
1082+
{
1083+
session.Log("certificate is being auto-generated; skipping private key permission grant");
1084+
return ActionResult.Success;
1085+
}
1086+
1087+
StoreLocation location = session.Get(GatewayProperties.certificateLocation);
1088+
StoreName storeName = session.Get(GatewayProperties.certificateStore);
1089+
string subjectName = session.Get(GatewayProperties.certificateName);
1090+
1091+
if (string.IsNullOrWhiteSpace(subjectName))
1092+
{
1093+
session.Log("certificateName is empty; skipping private key permission grant");
1094+
return ActionResult.Success;
1095+
}
1096+
1097+
// Use the same selection logic the Gateway service uses at startup. Fresh installs
1098+
// initialize gateway.json with tls_verify_strict=true (devolutions-gateway/src/config.rs
1099+
// generate_new), so we apply the strict filter here too.
1100+
CertificateSelection.Result selection = CertificateSelection.Select(
1101+
location, storeName, subjectName, strictMode: true);
1102+
1103+
if (selection.Selected == null)
1104+
{
1105+
if (selection.AllFiltered)
1106+
{
1107+
session.Log($"found {selection.MatchCount} certificate(s) matching subject {subjectName} in {location}\\{storeName}, but all were filtered out by strict-mode prerequisites (issues: {selection.FilteredReasons})");
1108+
}
1109+
else
1110+
{
1111+
session.Log($"no certificate matching subject {subjectName} found in {location}\\{storeName}");
1112+
}
1113+
return ActionResult.Success;
1114+
}
1115+
1116+
try
1117+
{
1118+
X509Certificate2 certificate = selection.Selected;
1119+
session.Log($"selected certificate {certificate.Thumbprint} (NotAfter={certificate.NotAfter:o}) for subject {subjectName}");
1120+
1121+
if (PrivateKeyPermissions.HasNetworkServiceReadPermission(certificate))
1122+
{
1123+
session.Log("NETWORK SERVICE already has Read access to the certificate's private key");
1124+
return ActionResult.Success;
1125+
}
1126+
1127+
if (PrivateKeyPermissions.TryGrantNetworkServiceReadPermission(certificate, out Exception grantError))
1128+
{
1129+
session.Log("granted NETWORK SERVICE Read access to the certificate's private key");
1130+
return ActionResult.Success;
1131+
}
1132+
1133+
session.Log($"failed to grant NETWORK SERVICE Read access to the certificate's private key: {grantError}");
1134+
}
1135+
finally
1136+
{
1137+
selection.Selected.Dispose();
1138+
}
1139+
}
1140+
catch (Exception e)
1141+
{
1142+
session.Log($"unexpected error setting certificate private key permissions: {e}");
1143+
}
1144+
1145+
return ActionResult.Success;
1146+
}
1147+
10721148
[CustomAction]
10731149
public static ActionResult SetUsersDatabaseFilePermissions(Session session)
10741150
{

package/WindowsManaged/Actions/GatewayActions.cs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,13 +354,48 @@ internal static class GatewayActions
354354
hide: true // Don't print the custom action data to logs, it might contain a password
355355
);
356356

357+
/// <summary>
358+
/// Grant NETWORK SERVICE Read permission on the selected system store certificate's private key
359+
/// </summary>
360+
/// <remarks>
361+
/// The Gateway service runs as NETWORK SERVICE and needs Read access on the private key file.
362+
/// Best effort: failures are logged but do not fail the install. Only fires when a system-store
363+
/// certificate is selected and the gateway is not auto-generating one.
364+
/// </remarks>
365+
private static readonly ElevatedManagedAction setCertificatePrivateKeyPermissions = new(
366+
new Id($"CA.{nameof(setCertificatePrivateKeyPermissions)}"),
367+
CustomActions.SetCertificatePrivateKeyPermissions,
368+
Return.ignore,
369+
When.After, new Step(configureCertificate.Id),
370+
new Condition(string.Join(" AND ", new[]
371+
{
372+
"(NOT Installed OR REINSTALL)",
373+
$"({GatewayProperties.configureGateway.Equal(true)})",
374+
$"({GatewayProperties.certificateMode.Equal(Constants.CertificateMode.System)})",
375+
$"({GatewayProperties.httpListenerScheme.Equal(Constants.HttpsProtocol)})",
376+
$"({GatewayProperties.configureNgrok.Equal(false)})",
377+
})),
378+
Sequence.InstallExecuteSequence)
379+
{
380+
Execute = Execute.deferred,
381+
Impersonate = false,
382+
UsesProperties = UseProperties(new IWixProperty[]
383+
{
384+
GatewayProperties.certificateLocation,
385+
GatewayProperties.certificateStore,
386+
GatewayProperties.certificateName,
387+
GatewayProperties.configureWebApp,
388+
GatewayProperties.generateCertificate,
389+
}),
390+
};
391+
357392
/// <summary>
358393
/// Configure the public key using PowerShell
359394
/// </summary>
360395
private static readonly ElevatedManagedAction configurePublicKey = BuildConfigureAction(
361396
$"CA.{nameof(configurePublicKey)}",
362397
CustomActions.ConfigurePublicKey,
363-
When.After, new Step(configureCertificate.Id),
398+
When.After, new Step(setCertificatePrivateKeyPermissions.Id),
364399
new IWixProperty[]
365400
{
366401
GatewayProperties.publicKeyFile,
@@ -500,6 +535,7 @@ private static ElevatedManagedAction BuildConfigureAction(
500535
configureAccessUri,
501536
configureListeners,
502537
configureCertificate,
538+
setCertificatePrivateKeyPermissions,
503539
configureNgrokListeners,
504540
configurePublicKey,
505541
configureWebApp,

0 commit comments

Comments
 (0)