Skip to content

Commit 2cbea14

Browse files
committed
Add self-signing feature
1 parent 5a6440a commit 2cbea14

File tree

6 files changed

+480
-67
lines changed

6 files changed

+480
-67
lines changed

src/EasySign.Cli/BundleCommandProvider.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,8 @@ internal class BundleCommandProvider : CommandProvider<Bundle>
1010
{
1111
private readonly ILogger _bundleLogger;
1212

13-
public BundleCommandProvider(ILogger logger, ILogger bundleLogger)
13+
public BundleCommandProvider(string appDirectory, ILogger logger, ILogger bundleLogger) : base(appDirectory, logger)
1414
{
15-
Logger = logger;
1615
_bundleLogger = bundleLogger;
1716
}
1817

@@ -28,7 +27,8 @@ public override RootCommand GetRootCommand()
2827
{
2928
Add,
3029
Sign,
31-
Verify
30+
Verify,
31+
SelfSign,
3232
};
3333

3434
return root;

src/EasySign.Cli/Program.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ namespace SAPTeam.EasySign.Cli
77
{
88
internal class Program
99
{
10+
public static string AppDirectory => AppDomain.CurrentDomain.BaseDirectory;
11+
1012
private static int Main(string[] args)
1113
{
1214
Log.Logger = new LoggerConfiguration()
1315
.Enrich.WithThreadId()
1416
.WriteTo.File(
15-
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "logs/log-.txt"),
17+
Path.Combine(AppDirectory, "logs/log-.txt"),
1618
rollingInterval: RollingInterval.Day,
1719
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Context}({ThreadId}) - {Message} {NewLine}{Exception}"
1820
)
@@ -28,7 +30,7 @@ private static int Main(string[] args)
2830
Microsoft.Extensions.Logging.ILogger commandProviderLogger = new SerilogLoggerFactory(Log.Logger.ForContext("Context", "CommandProvider"))
2931
.CreateLogger("CommandProvider");
3032

31-
RootCommand root = new BundleCommandProvider(commandProviderLogger, bundleLogger).GetRootCommand();
33+
RootCommand root = new BundleCommandProvider(AppDirectory, commandProviderLogger, bundleLogger).GetRootCommand();
3234
int exitCode = root.Invoke(args);
3335

3436
appLogger.Information("Shutting down EasySign CLI at {DateTime} with exit code {ExitCode}", DateTime.Now, exitCode);

src/EasySign.CommandLine/BundleWorker.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,29 @@ protected bool VerifyCertificate(X509Certificate2 certificate)
398398

399399
List<bool> verifyResults = [];
400400

401+
X509Certificate2? rootCA;
402+
if ((rootCA = GetSelfSigningRootCA()) != null)
403+
{
404+
Logger.LogDebug("Verifying certificate {cert} with self-signing root CA", certificate);
405+
406+
X509ChainPolicy policy = new X509ChainPolicy();
407+
policy.TrustMode = X509ChainTrustMode.CustomRootTrust;
408+
policy.CustomTrustStore.Add(rootCA);
409+
policy.VerificationFlags |= X509VerificationFlags.IgnoreNotTimeValid;
410+
policy.RevocationMode = X509RevocationMode.NoCheck;
411+
412+
bool selfSignVerification = Bundle.VerifyCertificate(certificate, out X509ChainStatus[] selfSignStatuses, policy: policy);
413+
verifyResults.Add(selfSignVerification);
414+
415+
Logger.LogInformation("Certificate verification with self-signing root CA for {cert}: {result}", certificate, selfSignVerification);
416+
417+
if (!selfSignVerification)
418+
{
419+
AnsiConsole.MarkupLine($"[{Color.Green}] Certificate Verification with Self-Signing Root CA Successful[/]");
420+
return true;
421+
}
422+
}
423+
401424
Logger.LogDebug("Verifying certificate {cert} with default verification policy", certificate);
402425
bool defaultVerification = Bundle.VerifyCertificate(certificate, out X509ChainStatus[] statuses);
403426
verifyResults.Add(defaultVerification);
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Security.Cryptography.X509Certificates;
5+
using System.Security.Cryptography;
6+
using System.Text;
7+
using System.Threading.Tasks;
8+
using EnsureThat;
9+
10+
namespace SAPTeam.EasySign.CommandLine
11+
{
12+
/// <summary>
13+
/// Provides functionality for creating and importing X.509 certificates
14+
/// </summary>
15+
public static class CertificateUtilities
16+
{
17+
/// <summary>
18+
/// Prompts the user for certificate subject information and generates a standardized subject name.
19+
/// </summary>
20+
/// <returns>
21+
/// The formatted certificate subject string.
22+
/// </returns>
23+
public static string GetSubjectNameFromUser()
24+
{
25+
string? commonName = null;
26+
27+
while (string.IsNullOrEmpty(commonName))
28+
{
29+
Console.Write("Common Name (CN): ");
30+
commonName = Console.ReadLine();
31+
}
32+
33+
Console.Write("Organization (O) (optional): ");
34+
string? organization = Console.ReadLine();
35+
36+
Console.Write("Organizational Unit (OU) (optional): ");
37+
string? organizationalUnit = Console.ReadLine();
38+
39+
Console.Write("Locality (L) (optional): ");
40+
string? locality = Console.ReadLine();
41+
42+
Console.Write("State or Province (S) (optional): ");
43+
string? state = Console.ReadLine();
44+
45+
Console.Write("Country (C) (optional): ");
46+
string? country = Console.ReadLine();
47+
48+
return GenerateSubjectName(commonName, organization, organizationalUnit, locality, state, country);
49+
}
50+
51+
/// <summary>
52+
/// Generates a standardized certificate subject name.
53+
/// Only non-empty components are included.
54+
/// </summary>
55+
/// <param name="commonName">Common Name (CN) - required.</param>
56+
/// <param name="organization">Organization (O) - optional.</param>
57+
/// <param name="organizationalUnit">Organizational Unit (OU) - optional.</param>
58+
/// <param name="locality">Locality (L) - optional.</param>
59+
/// <param name="stateOrProvince">State or Province (S) - optional.</param>
60+
/// <param name="country">Country (C) - optional.</param>
61+
/// <returns>The formatted certificate subject string.</returns>
62+
public static string GenerateSubjectName(string commonName, string? organization, string? organizationalUnit, string? locality, string? stateOrProvince, string? country)
63+
{
64+
Ensure.String.IsNotNullOrEmpty(commonName, nameof(commonName));
65+
66+
var components = new List<string>
67+
{
68+
// Required fields
69+
$"CN={commonName}"
70+
};
71+
72+
// Optional fields: add only if they are not null or empty.
73+
if (!string.IsNullOrWhiteSpace(organization))
74+
{
75+
components.Add($"O={organization}");
76+
}
77+
78+
if (!string.IsNullOrWhiteSpace(organizationalUnit))
79+
{
80+
components.Add($"OU={organizationalUnit}");
81+
}
82+
83+
if (!string.IsNullOrWhiteSpace(locality))
84+
{
85+
components.Add($"L={locality}");
86+
}
87+
88+
if (!string.IsNullOrWhiteSpace(stateOrProvince))
89+
{
90+
components.Add($"S={stateOrProvince}");
91+
}
92+
93+
if (!string.IsNullOrWhiteSpace(country))
94+
{
95+
components.Add($"C={country}");
96+
}
97+
98+
// Combine with comma separators.
99+
return string.Join(", ", components);
100+
}
101+
102+
/// <summary>
103+
/// Retrieves a collection of certificates from a PFX file or the current user's certificate store.
104+
/// </summary>
105+
/// <param name="pfxFilePath">The path to the PFX file.</param>
106+
/// <param name="pfxFilePassword">The password for the PFX file.</param>
107+
/// <param name="pfxNoPasswordPrompt">Indicates whether to prompt for a password if not provided.</param>
108+
/// <returns>A collection of certificates.</returns>
109+
public static X509Certificate2Collection GetCertificates(string pfxFilePath, string pfxFilePassword, bool pfxNoPasswordPrompt)
110+
{
111+
X509Certificate2Collection collection;
112+
113+
if (!string.IsNullOrEmpty(pfxFilePath))
114+
{
115+
collection = LoadCertificatesFromPfx(pfxFilePath, pfxFilePassword, pfxNoPasswordPrompt);
116+
}
117+
else
118+
{
119+
try
120+
{
121+
X509Store store = new X509Store("MY", StoreLocation.CurrentUser);
122+
store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly);
123+
124+
collection = store.Certificates;
125+
}
126+
catch
127+
{
128+
collection = [];
129+
}
130+
}
131+
132+
return collection;
133+
}
134+
135+
/// <summary>
136+
/// Loads certificates from a PFX file.
137+
/// </summary>
138+
/// <param name="pfxFilePath">
139+
/// The path to the PFX file.
140+
/// </param>
141+
/// <param name="pfxFilePassword">
142+
/// The password for the PFX file.
143+
/// </param>
144+
/// <param name="pfxNoPasswordPrompt">
145+
/// Indicates whether to prompt for a password if not provided.
146+
/// </param>
147+
/// <returns></returns>
148+
private static X509Certificate2Collection LoadCertificatesFromPfx(string pfxFilePath, string? pfxFilePassword, bool pfxNoPasswordPrompt)
149+
{
150+
X509Certificate2Collection collection = [];
151+
152+
string pfpass = !string.IsNullOrEmpty(pfxFilePassword) ? pfxFilePassword : !pfxNoPasswordPrompt ? Utilities.SecurePrompt("Enter PFX File password (if needed): ") : "";
153+
154+
#if NET9_0_OR_GREATER
155+
X509Certificate2Collection tempCollection = X509CertificateLoader.LoadPkcs12CollectionFromFile(pfxFilePath, pfpass, X509KeyStorageFlags.EphemeralKeySet);
156+
#else
157+
X509Certificate2Collection tempCollection = [];
158+
tempCollection.Import(pfxFilePath, pfpass, X509KeyStorageFlags.EphemeralKeySet);
159+
#endif
160+
161+
IEnumerable<X509Certificate2> cond = tempCollection.Where(x => x.HasPrivateKey);
162+
if (cond.Any())
163+
{
164+
collection.AddRange(cond.ToArray());
165+
}
166+
else
167+
{
168+
collection.AddRange(tempCollection);
169+
}
170+
171+
return collection;
172+
}
173+
174+
/// <summary>
175+
/// Creates a self-signed certificate which acts as a Root Certificate Authority (CA).
176+
/// </summary>
177+
/// <param name="subjectName">The certificate subject.</param>
178+
/// <returns>A self-signed X509Certificate2 representing the Root CA.</returns>
179+
public static X509Certificate2 CreateSelfSignedCACertificate(string subjectName)
180+
{
181+
using (RSA rsa = RSA.Create(4096))
182+
{
183+
// Build the certificate request for the CA.
184+
var caRequest = new CertificateRequest(subjectName, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
185+
186+
// Mark this certificate as a Certificate Authority with the Basic Constraints extension.
187+
caRequest.CertificateExtensions.Add(
188+
new X509BasicConstraintsExtension(true, false, 0, true));
189+
190+
// Set key usages to allow certificate signing and CRL signing.
191+
caRequest.CertificateExtensions.Add(
192+
new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true));
193+
194+
// Add a Subject Key Identifier.
195+
caRequest.CertificateExtensions.Add(
196+
new X509SubjectKeyIdentifierExtension(caRequest.PublicKey, false));
197+
198+
// Create the self-signed certificate. Validity is set from yesterday to 10 years in the future.
199+
var rootCert = caRequest.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1),
200+
DateTimeOffset.UtcNow.AddYears(10));
201+
202+
// Export and re-import to mark the key as exportable (if needed for further signing).
203+
#if NET9_0_OR_GREATER
204+
var cert = X509CertificateLoader.LoadPkcs12(rootCert.Export(X509ContentType.Pfx), null);
205+
#else
206+
var cert = new X509Certificate2(rootCert.Export(X509ContentType.Pfx));
207+
#endif
208+
209+
return cert;
210+
}
211+
}
212+
213+
/// <summary>
214+
/// Creates a certificate and signs it using the provided Root CA certificate.
215+
/// </summary>
216+
/// <param name="subjectName">The subject name for the new certificate.</param>
217+
/// <param name="caCert">The Root CA certificate that will sign the new certificate. Must include the private key.</param>
218+
/// <returns>The issued X509Certificate2 signed by the provided Root CA.</returns>
219+
public static X509Certificate2 IssueCertificate(string subjectName, X509Certificate2 caCert)
220+
{
221+
using (RSA rsa = RSA.Create(2048))
222+
{
223+
// Build the certificate request for the issued certificate.
224+
var req = new CertificateRequest(subjectName, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
225+
226+
// This certificate is not a CA, so basic constraints are set accordingly.
227+
req.CertificateExtensions.Add(
228+
new X509BasicConstraintsExtension(false, false, 0, false));
229+
230+
// Use key usage flags appropriate for, e.g., a server certificate.
231+
req.CertificateExtensions.Add(
232+
new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, true));
233+
234+
// Add a Subject Key Identifier.
235+
req.CertificateExtensions.Add(
236+
new X509SubjectKeyIdentifierExtension(req.PublicKey, false));
237+
238+
// Generate a random serial number.
239+
byte[] serialNumber = new byte[16];
240+
using (RandomNumberGenerator rng = RandomNumberGenerator.Create())
241+
{
242+
rng.GetBytes(serialNumber);
243+
}
244+
245+
// Sign the new certificate with the CA certificate.
246+
// Note: The CA certificate must contain its private key for signing.
247+
using (RSA? caPrivateKey = caCert.GetRSAPrivateKey())
248+
{
249+
if (caPrivateKey == null)
250+
{
251+
throw new InvalidOperationException("The provided CA certificate does not contain a private key.");
252+
}
253+
254+
// Create the certificate valid from yesterday until 2 years in the future.
255+
var issuedCert = req.Create(caCert, DateTimeOffset.UtcNow.AddDays(-1),
256+
DateTimeOffset.UtcNow.AddYears(2), serialNumber);
257+
258+
// Export and re-import to ensure the certificate includes the private key.
259+
#if NET9_0_OR_GREATER
260+
var cert = X509CertificateLoader.LoadPkcs12(issuedCert.Export(X509ContentType.Pfx), null);
261+
#else
262+
var cert = new X509Certificate2(issuedCert.Export(X509ContentType.Pfx));
263+
#endif
264+
265+
return cert;
266+
}
267+
}
268+
}
269+
}
270+
}

0 commit comments

Comments
 (0)