Skip to content

Commit

Permalink
Add PEM support (#31)
Browse files Browse the repository at this point in the history
Add an option to specify the output format of the certificate.
When PEM is selected, three files are generated.
{certname}.key.pem with the private key
{certname}.cert.pem with the certificate
{certname}.chain.pem with the certificate chain

Also updates the project to build using the .net 7 toolchain
Library is still targeting .net standard 2.1 whereas the test and example projects are targeting .net 7.
  • Loading branch information
Kencdk authored Dec 30, 2022
1 parent 62183b2 commit 23b3d07
Show file tree
Hide file tree
Showing 18 changed files with 162 additions and 73 deletions.
4 changes: 2 additions & 2 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@
-->
<PropertyGroup>
<FileAlignment>512</FileAlignment>
<LangVersion>9.0</LangVersion>
<LangVersion>11.0</LangVersion>
</PropertyGroup>

<!-- include Ken Christensen in all assemblies -->
<PropertyGroup>
<Company>Ken Christensen</Company>
<Product>Kenc.ACMELib</Product>
<Authors>Ken Christensen</Authors>
<Copyright2021 Ken Christensen. All rights reserved.</Copyright>
<Copyright2022 Ken Christensen. All rights reserved.</Copyright>
</PropertyGroup>

<!-- Saving some paths that are used elsewhere in MSBuild settings and targets -->
Expand Down
7 changes: 7 additions & 0 deletions global.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"sdk": {
"version": "7.0.0",
"rollForward": "latestFeature",
"allowPrerelease": false
}
}
6 changes: 6 additions & 0 deletions nuget.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
</configuration>
4 changes: 2 additions & 2 deletions src/Examples/ACMEClient/ACMEClient.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

<PropertyGroup>
<OutputType>Exe</OutputType>
<LangVersion>9.0</LangVersion>
<TargetFramework>net5.0</TargetFramework>
<TargetFramework>net7.0</TargetFramework>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>

<ItemGroup>
Expand Down
14 changes: 7 additions & 7 deletions src/Examples/ACMEClient/PasswordInput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
/// <summary>
/// Based on https://stackoverflow.com/questions/3404421/password-masking-console-application
/// </summary>
internal class PasswordInput
internal partial class PasswordInput
{
private enum StdHandle
{
Expand All @@ -26,16 +26,16 @@ private enum ConsoleMode
private const int ENTER = 13, BACKSP = 8, CTRLBACKSP = 127;
private static readonly int[] Filtered = { 0, 27 /* escape */, 9 /*tab*/, 10 /* line feed */ };

[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr GetStdHandle(StdHandle nStdHandle);
[LibraryImport("kernel32.dll", SetLastError = true)]
private static partial IntPtr GetStdHandle(StdHandle nStdHandle);

[DllImport("kernel32.dll", SetLastError = true)]
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetConsoleMode(IntPtr hConsoleHandle, out int lpMode);
private static partial bool GetConsoleMode(IntPtr hConsoleHandle, out int lpMode);

[DllImport("kernel32.dll", SetLastError = true)]
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetConsoleMode(IntPtr hConsoleHandle, int dwMode);
private static partial bool SetConsoleMode(IntPtr hConsoleHandle, int dwMode);

public static SecureString ReadPassword()
{
Expand Down
15 changes: 7 additions & 8 deletions src/Examples/ACMEClient/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ internal class Program
{
private static readonly string keyPath = "acmeuser.key";

private static readonly HttpClient httpClient = new();

private static async Task Main()
{
Console.WriteLine("Kenc.ACMEClient example");
Expand Down Expand Up @@ -324,24 +326,21 @@ private static async Task<bool> ValidateChallengeCompletion(AuthorizationChallen
private static async Task<bool> ValidateHttpChallenge(string token, string expectedValue, string domain)
{
var domainUrl = domain.Replace("*", "");
var url = $"http://{domainUrl}/.well-known/acme-challenge/{token}";
var httpRequest = (HttpWebRequest)HttpWebRequest.Create(url);
var url = new Uri($"http://{domainUrl}/.well-known/acme-challenge/{token}");
try
{
var response = (HttpWebResponse)(await httpRequest.GetResponseAsync());
using HttpResponseMessage response = await httpClient.GetAsync(url);
if (response.StatusCode != HttpStatusCode.OK)
{
Console.WriteLine($"{url} Received unexpected status code: {response.StatusCode}");
return false;
}

using Stream stream = response.GetResponseStream();
using var streamReader = new StreamReader(stream);
var received = streamReader.ReadToEnd();
if (string.Compare(received, expectedValue, StringComparison.Ordinal) != 0)
var responseStr = await response.Content.ReadAsStringAsync();
if (string.Compare(responseStr, expectedValue, StringComparison.Ordinal) != 0)
{
Console.WriteLine($"{url} responded with unexpected value.");
Console.WriteLine(received);
Console.WriteLine(responseStr);
Console.WriteLine(expectedValue);
return false;
}
Expand Down
8 changes: 8 additions & 0 deletions src/Examples/CloudflareIntegration/CertificateExportFormat.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace CloudflareIntegration
{
public enum CertificateExportFormat
{
PFX,
PEM,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
<Sdk Name="Microsoft.Build.CentralPackageVersions" Version="2.0.79" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<LangVersion>9.0</LangVersion>
<TargetFramework>net5.0</TargetFramework>
<TargetFramework>net7.0</TargetFramework>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="CommandLineParser" />
<PackageReference Include="DnsClient" />
<PackageReference Include="Kenc.Cloudflare" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
</ItemGroup>

<ItemGroup>
Expand Down
3 changes: 3 additions & 0 deletions src/Examples/CloudflareIntegration/Options.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,8 @@ public class Options

[Option('k', "key", Required = false, HelpText = "RSA key to use for authenticating with ACME")]
public string Key { get; set; }

[Option('f', "format", Required = false, HelpText = "Format to export certificate as.")]
public CertificateExportFormat ExportFormat { get; set; } = CertificateExportFormat.PFX;
}
}
102 changes: 84 additions & 18 deletions src/Examples/CloudflareIntegration/OrderDomains.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,20 @@
using System.Net.Http;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading.Tasks;
using DnsClient;
using DnsClient.Protocol;
using Kenc.ACMELib;
using Kenc.ACMELib.ACMEObjects;
using Kenc.ACMELib.ACMEResponses;
using Kenc.ACMELib.Exceptions.API;
using Kenc.Cloudflare.Core;
using Kenc.Cloudflare.Core.Clients;
using Kenc.Cloudflare.Core.Clients.Enums;
using Kenc.Cloudflare.Core.Entities;
using Kenc.Cloudflare.Core.Exceptions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

/// <summary>
Expand All @@ -36,13 +39,21 @@ public class OrderDomains

public OrderDomains(Options options)
{
var cfgBuilder = new ConfigurationBuilder();
cfgBuilder.AddInMemoryCollection(new Dictionary<string, string>
{
["Username"] = options.Username,
["ApiKey"] = options.ApiKey,
["Endpoint"] = CloudflareAPIEndpoint.V4Endpoint.ToString(),
});
IConfiguration cfg = cfgBuilder.Build();

this.options = options;
var serviceCollection = new ServiceCollection();
serviceCollection.AddHttpClient();
serviceCollection.AddCloudflareClient(cfg);
ServiceProvider services = serviceCollection.BuildServiceProvider();
IHttpClientFactory httpClientFactory = services.GetRequiredService<System.Net.Http.IHttpClientFactory>();
cloudflareClient = new CloudflareClientFactory(options.Username, options.ApiKey, new CloudflareRestClientFactory(httpClientFactory), CloudflareAPIEndpoint.V4Endpoint)
.Create();
cloudflareClient = services.GetRequiredService<ICloudflareClient>();

// RSA service provider
var rsaCryptoServiceProvider = new RSACryptoServiceProvider(2048);
Expand Down Expand Up @@ -74,16 +85,23 @@ public async Task ValidateCloudflareConnection()
.ToList();

// list all zones.
IList<Zone> cloudflareZones = await cloudflareClient.Zones.ListAsync();
try
{
IList<Zone> cloudflareZones = await cloudflareClient.Zones.ListAsync();

CloudflareZones = cloudflareZones.Where(x => cleanedupDomains.Contains(x.Name, StringComparer.OrdinalIgnoreCase))
.ToDictionary(x => x.Name, x => x);
CloudflareZones = cloudflareZones.Where(x => cleanedupDomains.Contains(x.Name, StringComparer.OrdinalIgnoreCase))
.ToDictionary(x => x.Name, x => x);

var missingDomains = cleanedupDomains.Except(CloudflareZones.Keys, StringComparer.OrdinalIgnoreCase)
.ToList();
if (missingDomains.Any())
var missingDomains = cleanedupDomains.Except(CloudflareZones.Keys, StringComparer.OrdinalIgnoreCase)
.ToList();
if (missingDomains.Any())
{
throw new Exception($"The following domains are not accesible in the cloudflare account. {string.Join(',', missingDomains)}");
}
}
catch (CloudflareException ex)
{
throw new Exception($"The following domains are not accesible in the cloudflare account. {string.Join(',', missingDomains)}");
Console.WriteLine(ex.Message);
}
}

Expand Down Expand Up @@ -264,13 +282,55 @@ public async Task RetrieveCertificates(Order order)
// combine the two!
X509Certificate2 properCert = cert.CopyWithPrivateKey(certKey);

Program.LogLine("Enter password to secure PFX");
System.Security.SecureString password = PasswordInput.ReadPassword();
var pfxData = properCert.Export(X509ContentType.Pfx, password);
// do we export as PFX or PEM/KEY format?
if (options.ExportFormat == CertificateExportFormat.PFX)
{
Program.LogLine("Enter password to secure PFX");
System.Security.SecureString password = PasswordInput.ReadPassword();
var pfxData = properCert.Export(X509ContentType.Pfx, password);

var privateKeyFilename = $"{FixFilename(certOrder.Identifiers[0].Value)}.pfx";
await File.WriteAllBytesAsync(privateKeyFilename, pfxData);
Program.LogLine($"Private certificate written to file {privateKeyFilename}");
}
else if (options.ExportFormat == CertificateExportFormat.PEM)
{
var certificateBytes = cert.RawData;
var certificatePem = PemEncoding.Write("CERTIFICATE", certificateBytes);
var privKeyBytes = certKey.ExportPkcs8PrivateKey();
var privKeyPem = PemEncoding.Write("PRIVATE KEY", privKeyBytes);

var privateKeyFilename = $"{FixFilename(certOrder.Identifiers[0].Value)}.key.pem";
await File.WriteAllBytesAsync(privateKeyFilename, Encoding.ASCII.GetBytes(privKeyPem));

var privateKeyFilename = $"{FixFilename(certOrder.Identifiers[0].Value)}.pfx";
File.WriteAllBytes(privateKeyFilename, pfxData);
Program.LogLine($"Private certificate written to file {privateKeyFilename}");
var certificateFileName = $"{FixFilename(certOrder.Identifiers[0].Value)}.cert.pem";
await File.WriteAllBytesAsync(certificateFileName, Encoding.ASCII.GetBytes(certificatePem));

// build up the certificate chain and export as well
X509Chain ch = new();
ch.ChainPolicy.RevocationMode = X509RevocationMode.Online;
ch.Build(cert);

var stringBuilder = new StringBuilder();
foreach (X509ChainElement element in ch.ChainElements)
{
if (element.Certificate.Thumbprint.Equals(cert.Thumbprint, StringComparison.Ordinal))
{
continue;
}

var elementPem = PemEncoding.Write("CERTIFICATE", element.Certificate.RawData);
stringBuilder.Append(elementPem);
stringBuilder.AppendLine();
}

var certificateChainFileName = $"{FixFilename(certOrder.Identifiers[0].Value)}.chain.pem";
await File.WriteAllTextAsync(certificateChainFileName, stringBuilder.ToString());
}
else
{
throw new InvalidOperationException("Unsupported export format defined.");
}
}

/// <summary>
Expand All @@ -286,7 +346,13 @@ private static string GetRootDomain(string domain)

private static string FixFilename(string filename)
{
return filename.Replace("*", "");
filename = filename.Replace("*", "");
if (filename.StartsWith('.'))
{
filename = filename.Remove(0, 1);
}

return filename;
}

private static async Task<Order> NewOrderAsync(ACMEClient acmeClient, IEnumerable<OrderIdentifier> domains)
Expand Down Expand Up @@ -368,7 +434,7 @@ private static async Task ValidateChallengeCompletion(AuthorizationChallenge cha
await Task.Delay(delay);
}

throw new Exception($"Failed to validate {domainName}");
throw new TimeoutException($"Failed to validate {domainName}");
}
}
}
14 changes: 7 additions & 7 deletions src/Examples/CloudflareIntegration/PasswordInput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
/// <summary>
/// Based on https://stackoverflow.com/questions/3404421/password-masking-console-application
/// </summary>
internal class PasswordInput
internal partial class PasswordInput
{
private enum StdHandle
{
Expand All @@ -26,16 +26,16 @@ private enum ConsoleMode
private const int ENTER = 13, BACKSP = 8, CTRLBACKSP = 127;
private static readonly int[] Filtered = { 0, 27 /* escape */, 9 /*tab*/, 10 /* line feed */ };

[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr GetStdHandle(StdHandle nStdHandle);
[LibraryImport("kernel32.dll", SetLastError = true)]
private static partial IntPtr GetStdHandle(StdHandle nStdHandle);

[DllImport("kernel32.dll", SetLastError = true)]
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetConsoleMode(IntPtr hConsoleHandle, out int lpMode);
private static partial bool GetConsoleMode(IntPtr hConsoleHandle, out int lpMode);

[DllImport("kernel32.dll", SetLastError = true)]
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetConsoleMode(IntPtr hConsoleHandle, int dwMode);
private static partial bool SetConsoleMode(IntPtr hConsoleHandle, int dwMode);

public static SecureString ReadPassword()
{
Expand Down
6 changes: 2 additions & 4 deletions src/Libraries/ACMELib.Tests/Kenc.ACMELibCore.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Microsoft.Build.CentralPackageVersions" Version="2.0.79" />
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>

<TargetFramework>net7.0</TargetFramework>
<IsPackable>false</IsPackable>
<LangVersion>9.0</LangVersion>
</PropertyGroup>

<ItemGroup>
Expand All @@ -19,4 +17,4 @@
<ProjectReference Include="..\ACMELib\Kenc.ACMELib.csproj" />
</ItemGroup>

</Project>
</Project>
2 changes: 2 additions & 0 deletions src/Libraries/ACMELib.Tests/Mocks/CertificateMock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ internal class CertificateMock : X509Certificate2
{
private readonly string publicKeyString;

#pragma warning disable SYSLIB0026 // Type or member is obsolete
public CertificateMock(string publicKeyString)
{
this.publicKeyString = publicKeyString;
}
#pragma warning restore SYSLIB0026 // Type or member is obsolete

public override string GetPublicKeyString()
{
Expand Down
Loading

0 comments on commit 23b3d07

Please sign in to comment.