Skip to content

Commit 156023d

Browse files
authored
[HTTPS] Support exporting the dev-cert in PEM format and support importing an existing dev-cert in PFX (#23567)
* Support exporting the certificate key into PEM format * Support importing an existing https dev certificate into the certificate store
1 parent c1866c2 commit 156023d

File tree

9 files changed

+451
-25
lines changed

9 files changed

+451
-25
lines changed

src/ProjectTemplates/Shared/DevelopmentCertificate.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ private static string EnsureDevelopmentCertificates(string certificatePath, stri
3535
var manager = CertificateManager.Instance;
3636
var certificate = manager.CreateAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1));
3737
var certificateThumbprint = certificate.Thumbprint;
38-
manager.ExportCertificate(certificate, path: certificatePath, includePrivateKey: true, certificatePassword);
38+
manager.ExportCertificate(certificate, path: certificatePath, includePrivateKey: true, certificatePassword, CertificateKeyExportFormat.Pfx);
3939

4040
return certificateThumbprint;
4141
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
namespace Microsoft.AspNetCore.Certificates.Generation
5+
{
6+
internal enum CertificateKeyExportFormat
7+
{
8+
Pfx,
9+
Pem,
10+
}
11+
}

src/Shared/CertificateGeneration/CertificateManager.cs

Lines changed: 155 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ public EnsureCertificateResult EnsureAspNetCoreHttpsDevelopmentCertificate(
153153
bool trust = false,
154154
bool includePrivateKey = false,
155155
string password = null,
156+
CertificateKeyExportFormat keyExportFormat = CertificateKeyExportFormat.Pfx,
156157
bool isInteractive = true)
157158
{
158159
var result = EnsureCertificateResult.Succeeded;
@@ -170,6 +171,7 @@ public EnsureCertificateResult EnsureAspNetCoreHttpsDevelopmentCertificate(
170171
certificates = filteredCertificates;
171172

172173
X509Certificate2 certificate = null;
174+
var isNewCertificate = false;
173175
if (certificates.Any())
174176
{
175177
certificate = certificates.First();
@@ -216,6 +218,7 @@ public EnsureCertificateResult EnsureAspNetCoreHttpsDevelopmentCertificate(
216218
try
217219
{
218220
Log.CreateDevelopmentCertificateStart();
221+
isNewCertificate = true;
219222
certificate = CreateAspNetCoreHttpsDevelopmentCertificate(notBefore, notAfter);
220223
}
221224
catch (Exception e)
@@ -260,13 +263,13 @@ public EnsureCertificateResult EnsureAspNetCoreHttpsDevelopmentCertificate(
260263
{
261264
try
262265
{
263-
ExportCertificate(certificate, path, includePrivateKey, password);
266+
ExportCertificate(certificate, path, includePrivateKey, password, keyExportFormat);
264267
}
265268
catch (Exception e)
266269
{
267270
Log.ExportCertificateError(e.ToString());
268271
// We don't want to mask the original source of the error here.
269-
result = result != EnsureCertificateResult.Succeeded || result != EnsureCertificateResult.ValidCertificatePresent ?
272+
result = result != EnsureCertificateResult.Succeeded && result != EnsureCertificateResult.ValidCertificatePresent ?
270273
result :
271274
EnsureCertificateResult.ErrorExportingTheCertificate;
272275

@@ -292,9 +295,58 @@ public EnsureCertificateResult EnsureAspNetCoreHttpsDevelopmentCertificate(
292295
}
293296
}
294297

298+
DisposeCertificates(!isNewCertificate ? certificates : certificates.Append(certificate));
299+
295300
return result;
296301
}
297302

303+
internal ImportCertificateResult ImportCertificate(string certificatePath, string password)
304+
{
305+
if (!File.Exists(certificatePath))
306+
{
307+
Log.ImportCertificateMissingFile(certificatePath);
308+
return ImportCertificateResult.CertificateFileMissing;
309+
}
310+
311+
var certificates = ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false, requireExportable: false);
312+
if (certificates.Any())
313+
{
314+
Log.ImportCertificateExistingCertificates(ToCertificateDescription(certificates));
315+
return ImportCertificateResult.ExistingCertificatesPresent;
316+
}
317+
318+
X509Certificate2 certificate;
319+
try
320+
{
321+
Log.LoadCertificateStart(certificatePath);
322+
certificate = new X509Certificate2(certificatePath, password, X509KeyStorageFlags.Exportable | X509KeyStorageFlags.EphemeralKeySet);
323+
Log.LoadCertificateEnd(GetDescription(certificate));
324+
}
325+
catch (Exception e)
326+
{
327+
Log.LoadCertificateError(e.ToString());
328+
return ImportCertificateResult.InvalidCertificate;
329+
}
330+
331+
if (!IsHttpsDevelopmentCertificate(certificate))
332+
{
333+
Log.NoHttpsDevelopmentCertificate(GetDescription(certificate));
334+
return ImportCertificateResult.NoDevelopmentHttpsCertificate;
335+
}
336+
337+
try
338+
{
339+
SaveCertificate(certificate);
340+
}
341+
catch (Exception e)
342+
{
343+
Log.SaveCertificateInStoreError(e.ToString());
344+
return ImportCertificateResult.ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore;
345+
}
346+
347+
return ImportCertificateResult.Succeeded;
348+
}
349+
298350
public void CleanupHttpsCertificates()
299351
{
300352
// On OS X we don't have a good way to manage trusted certificates in the system keychain
@@ -329,7 +381,7 @@ public void CleanupHttpsCertificates()
329381

330382
protected abstract IList<X509Certificate2> GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation);
331383

332-
internal void ExportCertificate(X509Certificate2 certificate, string path, bool includePrivateKey, string password)
384+
internal void ExportCertificate(X509Certificate2 certificate, string path, bool includePrivateKey, string password, CertificateKeyExportFormat format)
333385
{
334386
Log.ExportCertificateStart(GetDescription(certificate), path, includePrivateKey);
335387
if (includePrivateKey && password == null)
@@ -345,15 +397,69 @@ internal void ExportCertificate(X509Certificate2 certificate, string path, bool
345397
}
346398

347399
byte[] bytes;
400+
byte[] keyBytes;
401+
byte[] pemEnvelope = null;
402+
RSA key = null;
403+
348404
try
349405
{
350-
bytes = includePrivateKey ? certificate.Export(X509ContentType.Pkcs12, password) : certificate.Export(X509ContentType.Cert);
406+
if (includePrivateKey)
407+
{
408+
switch (format)
409+
{
410+
case CertificateKeyExportFormat.Pfx:
411+
bytes = certificate.Export(X509ContentType.Pkcs12, password);
412+
break;
413+
case CertificateKeyExportFormat.Pem:
414+
key = certificate.GetRSAPrivateKey();
415+
416+
char[] pem;
417+
if (password != null)
418+
{
419+
keyBytes = key.ExportEncryptedPkcs8PrivateKey(password, new PbeParameters(PbeEncryptionAlgorithm.Aes256Cbc, HashAlgorithmName.SHA256, 100000));
420+
pem = PemEncoding.Write("ENCRYPTED PRIVATE KEY", keyBytes);
421+
pemEnvelope = Encoding.ASCII.GetBytes(pem);
422+
}
423+
else
424+
{
425+
// Export the key first to an encrypted PEM to avoid issues with System.Security.Cryptography.Cng indicating that the operation is not supported.
426+
// This is likely by design to avoid exporting the key by mistake.
427+
// To bypass it, we export the certificate to pem temporarily and then we import it and export it as unprotected PEM.
428+
keyBytes = key.ExportEncryptedPkcs8PrivateKey("", new PbeParameters(PbeEncryptionAlgorithm.Aes256Cbc, HashAlgorithmName.SHA256, 1));
429+
pem = PemEncoding.Write("ENCRYPTED PRIVATE KEY", keyBytes);
430+
key.Dispose();
431+
key = RSA.Create();
432+
key.ImportFromEncryptedPem(pem, "");
433+
Array.Clear(keyBytes, 0, keyBytes.Length);
434+
Array.Clear(pem, 0, pem.Length);
435+
keyBytes = key.ExportPkcs8PrivateKey();
436+
pem = PemEncoding.Write("PRIVATE KEY", keyBytes);
437+
pemEnvelope = Encoding.ASCII.GetBytes(pem);
438+
}
439+
440+
Array.Clear(keyBytes, 0, keyBytes.Length);
441+
Array.Clear(pem, 0, pem.Length);
442+
443+
bytes = certificate.Export(X509ContentType.Cert);
444+
break;
445+
default:
446+
throw new InvalidOperationException("Unknown format.");
447+
}
448+
}
449+
else
450+
{
451+
bytes = certificate.Export(X509ContentType.Cert);
452+
}
351453
}
352454
catch (Exception e)
353455
{
354456
Log.ExportCertificateError(e.ToString());
355457
throw;
356458
}
459+
finally
460+
{
461+
key?.Dispose();
462+
}
357463

358464
try
359465
{
@@ -369,6 +475,25 @@ internal void ExportCertificate(X509Certificate2 certificate, string path, bool
369475
{
370476
Array.Clear(bytes, 0, bytes.Length);
371477
}
478+
479+
if (includePrivateKey && format == CertificateKeyExportFormat.Pem)
480+
{
481+
try
482+
{
483+
var keyPath = Path.ChangeExtension(path, ".key");
484+
Log.WritePemKeyToDisk(keyPath);
485+
File.WriteAllBytes(keyPath, pemEnvelope);
486+
}
487+
catch (Exception ex)
488+
{
489+
Log.WritePemKeyToDiskError(ex.ToString());
490+
throw;
491+
}
492+
finally
493+
{
494+
Array.Clear(pemEnvelope, 0, pemEnvelope.Length);
495+
}
496+
}
372497
}
373498

374499
internal X509Certificate2 CreateAspNetCoreHttpsDevelopmentCertificate(DateTimeOffset notBefore, DateTimeOffset notAfter)
@@ -496,7 +621,7 @@ internal X509Certificate2 CreateSelfSignedCertificate(
496621
DateTimeOffset notBefore,
497622
DateTimeOffset notAfter)
498623
{
499-
var key = CreateKeyMaterial(RSAMinimumKeySizeInBits);
624+
using var key = CreateKeyMaterial(RSAMinimumKeySizeInBits);
500625

501626
var request = new CertificateRequest(subject, key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
502627
foreach (var extension in extensions)
@@ -745,6 +870,31 @@ public void ExportCertificateStart(string certificate, string path, bool include
745870

746871
[Event(56, Level = EventLevel.Error)]
747872
internal void MacOSAddCertificateToKeyChainError(int exitCode) => WriteEvent(56, $"An error has ocurred while importing the certificate to the keychain: {exitCode}.");
873+
874+
875+
[Event(57, Level = EventLevel.Verbose)]
876+
public void WritePemKeyToDisk(string path) => WriteEvent(57, $"Writing the certificate to: {path}.");
877+
878+
[Event(58, Level = EventLevel.Error)]
879+
public void WritePemKeyToDiskError(string ex) => WriteEvent(58, $"An error has ocurred while writing the certificate to disk: {ex}.");
880+
881+
[Event(59, Level = EventLevel.Error)]
882+
internal void ImportCertificateMissingFile(string certificatePath) => WriteEvent(59, $"The file '{certificatePath}' does not exist.");
883+
884+
[Event(60, Level = EventLevel.Error)]
885+
internal void ImportCertificateExistingCertificates(string certificateDescription) => WriteEvent(60, $"One or more HTTPS certificates exist '{certificateDescription}'.");
886+
887+
[Event(61, Level = EventLevel.Verbose)]
888+
internal void LoadCertificateStart(string certificatePath) => WriteEvent(61, $"Loading certificate from path '{certificatePath}'.");
889+
890+
[Event(62, Level = EventLevel.Verbose)]
891+
internal void LoadCertificateEnd(string description) => WriteEvent(62, $"The certificate '{description}' has been loaded successfully.");
892+
893+
[Event(63, Level = EventLevel.Error)]
894+
internal void LoadCertificateError(string ex) => WriteEvent(63, $"An error has ocurred while loading the certificate from disk: {ex}.");
895+
896+
[Event(64, Level = EventLevel.Error)]
897+
internal void NoHttpsDevelopmentCertificate(string description) => WriteEvent(64, $"The provided certificate '{description}' is not a valid ASP.NET Core HTTPS development certificate.");
748898
}
749899

750900
internal class UserCancelledTrustException : Exception
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
namespace Microsoft.AspNetCore.Certificates.Generation
5+
{
6+
internal enum ImportCertificateResult
7+
{
8+
Succeeded = 1,
9+
CertificateFileMissing,
10+
InvalidCertificate,
11+
NoDevelopmentHttpsCertificate,
12+
ExistingCertificatesPresent,
13+
ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore,
14+
}
15+
}
16+

src/Shared/CertificateGeneration/MacOSCertificateManager.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ protected override void TrustCertificateCore(X509Certificate2 publicCertificate)
5454
var tmpFile = Path.GetTempFileName();
5555
try
5656
{
57-
ExportCertificate(publicCertificate, tmpFile, includePrivateKey: false, password: null);
57+
ExportCertificate(publicCertificate, tmpFile, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pfx);
5858
Log.MacOSTrustCommandStart($"{MacOSTrustCertificateCommandLine} {MacOSTrustCertificateCommandLineArguments}{tmpFile}");
5959
using (var process = Process.Start(MacOSTrustCertificateCommandLine, MacOSTrustCertificateCommandLineArguments + tmpFile))
6060
{
@@ -94,7 +94,7 @@ internal override CheckCertificateStateResult CheckCertificateState(X509Certific
9494
// Tries to use the certificate key to validate it can't access it
9595
try
9696
{
97-
var rsa = candidate.GetRSAPrivateKey();
97+
using var rsa = candidate.GetRSAPrivateKey();
9898
if (rsa == null)
9999
{
100100
return new CheckCertificateStateResult(false, InvalidCertificateState);

src/Shared/CertificateGeneration/UnixCertificateManager.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ internal UnixCertificateManager(string subject, int version)
2020
protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certificate)
2121
{
2222
var export = certificate.Export(X509ContentType.Pkcs12, "");
23+
certificate.Dispose();
2324
certificate = new X509Certificate2(export, "", X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable);
2425
Array.Clear(export, 0, export.Length);
2526

src/Shared/CertificateGeneration/WindowsCertificateManager.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@ protected override bool IsExportable(X509Certificate2 c)
2626
// For the first run experience we don't need to know if the certificate can be exported.
2727
return true;
2828
#else
29-
return (c.GetRSAPrivateKey() is RSACryptoServiceProvider rsaPrivateKey &&
29+
using var key = c.GetRSAPrivateKey();
30+
return (key is RSACryptoServiceProvider rsaPrivateKey &&
3031
rsaPrivateKey.CspKeyContainerInfo.Exportable) ||
31-
(c.GetRSAPrivateKey() is RSACng cngPrivateKey &&
32+
(key is RSACng cngPrivateKey &&
3233
cngPrivateKey.Key.ExportPolicy == CngExportPolicies.AllowExport);
3334
#endif
3435
}
@@ -49,6 +50,7 @@ protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certifi
4950
// On non OSX systems we need to export the certificate and import it so that the transient
5051
// key that we generated gets persisted.
5152
var export = certificate.Export(X509ContentType.Pkcs12, "");
53+
certificate.Dispose();
5254
certificate = new X509Certificate2(export, "", X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable);
5355
Array.Clear(export, 0, export.Length);
5456
certificate.FriendlyName = AspNetHttpsOidFriendlyName;
@@ -65,7 +67,7 @@ protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certifi
6567

6668
protected override void TrustCertificateCore(X509Certificate2 certificate)
6769
{
68-
var publicCertificate = new X509Certificate2(certificate.Export(X509ContentType.Cert));
70+
using var publicCertificate = new X509Certificate2(certificate.Export(X509ContentType.Cert));
6971

7072
publicCertificate.FriendlyName = certificate.FriendlyName;
7173

0 commit comments

Comments
 (0)