Skip to content

Commit

Permalink
feat(cli): add certificate generator command (#620)
Browse files Browse the repository at this point in the history
BREAKING CHANGE: This allows the operator
to create a valid selfsigned CA and server
certificate ad-hoc in the cluster when using
webhooks. Instead of generating the certificates
locally and using them as config-map in kustomize,
the operator can run the cli to generate the service
certificate.
  • Loading branch information
buehler authored Sep 28, 2023
1 parent 7cac2ad commit 8660f43
Show file tree
Hide file tree
Showing 7 changed files with 306 additions and 0 deletions.
9 changes: 9 additions & 0 deletions src/KubeOps.Cli/Arguments.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,13 @@ var slnFile
"If omitted, the current directory is searched for a *.csproj or *.sln file. " +
"If an *.sln file is used, all projects in the solution (with the newest framework) will be searched for entities. " +
"This behaviour can be filtered by using the --project and --target-framework option.");

public static readonly Argument<string> CertificateServerName = new(
"name",
"The server name for the certificate (name of the service/deployment).");

public static readonly Argument<string> CertificateServerNamespace = new(
"namespace",
() => "default",
"The Kubernetes namespace that the operator will be run.");
}
192 changes: 192 additions & 0 deletions src/KubeOps.Cli/Commands/Generator/CertificateGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
using System.CommandLine;
using System.CommandLine.Invocation;
using System.Text;

using KubeOps.Cli.Output;

using Org.BouncyCastle.Asn1.X509;
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Operators;
using Org.BouncyCastle.Crypto.Prng;
using Org.BouncyCastle.Math;
using Org.BouncyCastle.OpenSsl;
using Org.BouncyCastle.Security;
using Org.BouncyCastle.Utilities;
using Org.BouncyCastle.X509;
using Org.BouncyCastle.X509.Extension;

using Spectre.Console;

namespace KubeOps.Cli.Commands.Generator;

internal static class CertificateGenerator
{
public static Command Command
{
get
{
var cmd = new Command("certificates", "Generates a CA and a server certificate.")
{
Options.OutputPath, Arguments.CertificateServerName, Arguments.CertificateServerNamespace,
};
cmd.AddAlias("cert");
cmd.SetHandler(ctx => Handler(AnsiConsole.Console, ctx));

return cmd;
}
}

internal static async Task Handler(IAnsiConsole console, InvocationContext ctx)
{
var outPath = ctx.ParseResult.GetValueForOption(Options.OutputPath);
var result = new ResultOutput(console, OutputFormat.Plain);

console.MarkupLine("Generate [cyan]CA[/] certificate and private key.");
var (caCert, caKey) = CreateCaCertificate();

result.Add("ca.pem", ToPem(caCert));
result.Add("ca-key.pem", ToPem(caKey));

console.MarkupLine("Generate [cyan]server[/] certificate and private key.");
var (srvCert, srvKey) = CreateServerCertificate(
(caCert, caKey),
ctx.ParseResult.GetValueForArgument(Arguments.CertificateServerName),
ctx.ParseResult.GetValueForArgument(Arguments.CertificateServerNamespace));

result.Add("svc.pem", ToPem(srvCert));
result.Add("svc-key.pem", ToPem(srvKey));

if (outPath is not null)
{
await result.Write(outPath);
}
else
{
result.Write();
}
}

private static string ToPem(object obj)
{
var sb = new StringBuilder();
using var writer = new PemWriter(new StringWriter(sb));
writer.WriteObject(obj);
return sb.ToString();
}

private static (X509Certificate Certificate, AsymmetricCipherKeyPair Key) CreateCaCertificate()
{
var randomGenerator = new CryptoApiRandomGenerator();
var random = new SecureRandom(randomGenerator);

// The Certificate Generator
var certificateGenerator = new X509V3CertificateGenerator();

// Serial Number
var serialNumber = BigIntegers.CreateRandomInRange(BigInteger.One, BigInteger.ValueOf(long.MaxValue), random);
certificateGenerator.SetSerialNumber(serialNumber);

// Issuer and Subject Name
var name = new X509Name("CN=Operator Root CA, C=DEV, L=Kubernetes");
certificateGenerator.SetIssuerDN(name);
certificateGenerator.SetSubjectDN(name);

// Valid For
var notBefore = DateTime.UtcNow.Date;
var notAfter = notBefore.AddYears(5);
certificateGenerator.SetNotBefore(notBefore);
certificateGenerator.SetNotAfter(notAfter);

// Cert Extensions
certificateGenerator.AddExtension(
X509Extensions.BasicConstraints,
true,
new BasicConstraints(true));
certificateGenerator.AddExtension(
X509Extensions.KeyUsage,
true,
new KeyUsage(KeyUsage.KeyCertSign | KeyUsage.CrlSign | KeyUsage.KeyEncipherment));

// Subject Public Key
const int keyStrength = 256;
var keyGenerator = new ECKeyPairGenerator("ECDSA");
keyGenerator.Init(new KeyGenerationParameters(random, keyStrength));
var key = keyGenerator.GenerateKeyPair();

certificateGenerator.SetPublicKey(key.Public);

var signatureFactory = new Asn1SignatureFactory("SHA512WITHECDSA", key.Private, random);
var certificate = certificateGenerator.Generate(signatureFactory);

return (certificate, key);
}

private static (X509Certificate Certificate, AsymmetricCipherKeyPair Key) CreateServerCertificate(
(X509Certificate Certificate, AsymmetricCipherKeyPair Key) ca, string serverName, string serverNamespace)
{
var randomGenerator = new CryptoApiRandomGenerator();
var random = new SecureRandom(randomGenerator);

// The Certificate Generator
var certificateGenerator = new X509V3CertificateGenerator();

// Serial Number
var serialNumber = BigIntegers.CreateRandomInRange(BigInteger.One, BigInteger.ValueOf(long.MaxValue), random);
certificateGenerator.SetSerialNumber(serialNumber);

// Issuer and Subject Name
certificateGenerator.SetIssuerDN(ca.Certificate.SubjectDN);
certificateGenerator.SetSubjectDN(new X509Name("CN=Operator Service, C=DEV, L=Kubernetes"));

// Valid For
var notBefore = DateTime.UtcNow.Date;
var notAfter = notBefore.AddYears(5);
certificateGenerator.SetNotBefore(notBefore);
certificateGenerator.SetNotAfter(notAfter);

// Cert Extensions
certificateGenerator.AddExtension(
X509Extensions.BasicConstraints,
false,
new BasicConstraints(false));
certificateGenerator.AddExtension(
X509Extensions.KeyUsage,
true,
new KeyUsage(KeyUsage.NonRepudiation | KeyUsage.KeyEncipherment | KeyUsage.DigitalSignature));
certificateGenerator.AddExtension(
X509Extensions.ExtendedKeyUsage,
false,
new ExtendedKeyUsage(KeyPurposeID.id_kp_clientAuth, KeyPurposeID.id_kp_serverAuth));
certificateGenerator.AddExtension(
X509Extensions.SubjectKeyIdentifier,
false,
new SubjectKeyIdentifierStructure(ca.Key.Public));
certificateGenerator.AddExtension(
X509Extensions.AuthorityKeyIdentifier,
false,
new AuthorityKeyIdentifierStructure(ca.Certificate));
certificateGenerator.AddExtension(
X509Extensions.SubjectAlternativeName,
false,
new GeneralNames(new[]
{
new GeneralName(GeneralName.DnsName, $"{serverName}.{serverNamespace}.svc"),
new GeneralName(GeneralName.DnsName, $"*.{serverNamespace}.svc"),
new GeneralName(GeneralName.DnsName, "*.svc"),
}));

// Subject Public Key
const int keyStrength = 256;
var keyGenerator = new ECKeyPairGenerator("ECDSA");
keyGenerator.Init(new KeyGenerationParameters(random, keyStrength));
var key = keyGenerator.GenerateKeyPair();

certificateGenerator.SetPublicKey(key.Public);

var signatureFactory = new Asn1SignatureFactory("SHA512WITHECDSA", ca.Key.Private, random);
var certificate = certificateGenerator.Generate(signatureFactory);

return (certificate, key);
}
}
1 change: 1 addition & 0 deletions src/KubeOps.Cli/Commands/Generator/Generator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public static Command Command
{
var cmd = new Command("generator", "Generates elements related to an operator.")
{
CertificateGenerator.Command,
CrdGenerator.Command,
RbacGenerator.Command,
};
Expand Down
1 change: 1 addition & 0 deletions src/KubeOps.Cli/KubeOps.Cli.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.2.1" />
<PackageReference Include="KubernetesClient" Version="12.0.16"/>
<PackageReference Include="Microsoft.Build.Locator" Version="1.6.10" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.7.0"/>
Expand Down
5 changes: 5 additions & 0 deletions src/KubeOps.Cli/Output/OutputFormat.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,9 @@ internal enum OutputFormat
/// Format the output in Kubernetes JSON style.
/// </summary>
Json,

/// <summary>
/// Format the output in plain text style.
/// </summary>
Plain,
}
1 change: 1 addition & 0 deletions src/KubeOps.Cli/Output/ResultOutput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public void Write()
{
OutputFormat.Yaml => KubernetesYaml.Serialize(data),
OutputFormat.Json => KubernetesJson.Serialize(data),
OutputFormat.Plain => data.ToString() ?? string.Empty,
_ => throw new ArgumentException("Unknown output format."),
};
}
97 changes: 97 additions & 0 deletions test/KubeOps.Cli.Test/Generator/CertificateGenerator.Test.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
using System.CommandLine;
using System.CommandLine.Invocation;

using FluentAssertions;

using KubeOps.Cli.Commands.Generator;

using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.OpenSsl;
using Org.BouncyCastle.X509;

using Spectre.Console.Testing;

namespace KubeOps.Cli.Test.Generator;

public class CertificateGeneratorTest
{
[Fact]
public async Task Should_Execute()
{
var console = new TestConsole();

var cmd = CertificateGenerator.Command;
var ctx = new InvocationContext(
cmd.Parse("server", "namespace"));

await CertificateGenerator.Handler(console, ctx);

ctx.ExitCode.Should().Be(ExitCodes.Success);
}

[Theory]
[InlineData("ca.pem")]
[InlineData("ca-key.pem")]
[InlineData("svc.pem")]
[InlineData("svc-key.pem")]
public async Task Should_Generate_Certificate_Files(string file)
{
var console = new TestConsole();

var cmd = CertificateGenerator.Command;
var ctx = new InvocationContext(
cmd.Parse("server", "namespace"));

await CertificateGenerator.Handler(console, ctx);

console.Output.Should().Contain($"File: {file}");
}

[Fact]
public async Task Should_Generate_Valid_Certificates()
{
var console = new TestConsole();

var cmd = CertificateGenerator.Command;
var ctx = new InvocationContext(
cmd.Parse("server", "namespace"));

await CertificateGenerator.Handler(console, ctx);

var output = console.Lines.ToArray();
var caCertString = string.Join('\n', output[4..15]);
var caCertKeyString = string.Join('\n', output[18..23]);
var srvCertString = string.Join('\n', output[26..42]);
var srvCertKeyString = string.Join('\n', output[45..50]);

if (new PemReader(new StringReader(caCertString)).ReadObject() is not X509Certificate caCert)
{
Assert.Fail("Could not parse CA certificate.");
return;
}

if (new PemReader(new StringReader(caCertKeyString)).ReadObject() is not AsymmetricCipherKeyPair caKey)
{
Assert.Fail("Could not parse CA private key.");
return;
}

if (new PemReader(new StringReader(srvCertString)).ReadObject() is not X509Certificate srvCert)
{
Assert.Fail("Could not parse server certificate.");
return;
}

if (new PemReader(new StringReader(srvCertKeyString)).ReadObject() is not AsymmetricCipherKeyPair)
{
Assert.Fail("Could not parse server private key.");
return;
}

caCert.IsValidNow.Should().BeTrue();
caCert.Verify(caKey.Public);

srvCert.IsValidNow.Should().BeTrue();
srvCert.Verify(caKey.Public);
}
}

0 comments on commit 8660f43

Please sign in to comment.