Skip to content

Commit 895a8d0

Browse files
committed
feat(cli): add certificate generator command (#620)
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.
1 parent 03cd894 commit 895a8d0

File tree

7 files changed

+306
-0
lines changed

7 files changed

+306
-0
lines changed

src/KubeOps.Cli/Arguments.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,13 @@ var slnFile
3232
"If omitted, the current directory is searched for a *.csproj or *.sln file. " +
3333
"If an *.sln file is used, all projects in the solution (with the newest framework) will be searched for entities. " +
3434
"This behaviour can be filtered by using the --project and --target-framework option.");
35+
36+
public static readonly Argument<string> CertificateServerName = new(
37+
"name",
38+
"The server name for the certificate (name of the service/deployment).");
39+
40+
public static readonly Argument<string> CertificateServerNamespace = new(
41+
"namespace",
42+
() => "default",
43+
"The Kubernetes namespace that the operator will be run.");
3544
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
using System.CommandLine;
2+
using System.CommandLine.Invocation;
3+
using System.Text;
4+
5+
using KubeOps.Cli.Output;
6+
7+
using Org.BouncyCastle.Asn1.X509;
8+
using Org.BouncyCastle.Crypto;
9+
using Org.BouncyCastle.Crypto.Generators;
10+
using Org.BouncyCastle.Crypto.Operators;
11+
using Org.BouncyCastle.Crypto.Prng;
12+
using Org.BouncyCastle.Math;
13+
using Org.BouncyCastle.OpenSsl;
14+
using Org.BouncyCastle.Security;
15+
using Org.BouncyCastle.Utilities;
16+
using Org.BouncyCastle.X509;
17+
using Org.BouncyCastle.X509.Extension;
18+
19+
using Spectre.Console;
20+
21+
namespace KubeOps.Cli.Commands.Generator;
22+
23+
internal static class CertificateGenerator
24+
{
25+
public static Command Command
26+
{
27+
get
28+
{
29+
var cmd = new Command("certificates", "Generates a CA and a server certificate.")
30+
{
31+
Options.OutputPath, Arguments.CertificateServerName, Arguments.CertificateServerNamespace,
32+
};
33+
cmd.AddAlias("cert");
34+
cmd.SetHandler(ctx => Handler(AnsiConsole.Console, ctx));
35+
36+
return cmd;
37+
}
38+
}
39+
40+
internal static async Task Handler(IAnsiConsole console, InvocationContext ctx)
41+
{
42+
var outPath = ctx.ParseResult.GetValueForOption(Options.OutputPath);
43+
var result = new ResultOutput(console, OutputFormat.Plain);
44+
45+
console.MarkupLine("Generate [cyan]CA[/] certificate and private key.");
46+
var (caCert, caKey) = CreateCaCertificate();
47+
48+
result.Add("ca.pem", ToPem(caCert));
49+
result.Add("ca-key.pem", ToPem(caKey));
50+
51+
console.MarkupLine("Generate [cyan]server[/] certificate and private key.");
52+
var (srvCert, srvKey) = CreateServerCertificate(
53+
(caCert, caKey),
54+
ctx.ParseResult.GetValueForArgument(Arguments.CertificateServerName),
55+
ctx.ParseResult.GetValueForArgument(Arguments.CertificateServerNamespace));
56+
57+
result.Add("svc.pem", ToPem(srvCert));
58+
result.Add("svc-key.pem", ToPem(srvKey));
59+
60+
if (outPath is not null)
61+
{
62+
await result.Write(outPath);
63+
}
64+
else
65+
{
66+
result.Write();
67+
}
68+
}
69+
70+
private static string ToPem(object obj)
71+
{
72+
var sb = new StringBuilder();
73+
using var writer = new PemWriter(new StringWriter(sb));
74+
writer.WriteObject(obj);
75+
return sb.ToString();
76+
}
77+
78+
private static (X509Certificate Certificate, AsymmetricCipherKeyPair Key) CreateCaCertificate()
79+
{
80+
var randomGenerator = new CryptoApiRandomGenerator();
81+
var random = new SecureRandom(randomGenerator);
82+
83+
// The Certificate Generator
84+
var certificateGenerator = new X509V3CertificateGenerator();
85+
86+
// Serial Number
87+
var serialNumber = BigIntegers.CreateRandomInRange(BigInteger.One, BigInteger.ValueOf(long.MaxValue), random);
88+
certificateGenerator.SetSerialNumber(serialNumber);
89+
90+
// Issuer and Subject Name
91+
var name = new X509Name("CN=Operator Root CA, C=DEV, L=Kubernetes");
92+
certificateGenerator.SetIssuerDN(name);
93+
certificateGenerator.SetSubjectDN(name);
94+
95+
// Valid For
96+
var notBefore = DateTime.UtcNow.Date;
97+
var notAfter = notBefore.AddYears(5);
98+
certificateGenerator.SetNotBefore(notBefore);
99+
certificateGenerator.SetNotAfter(notAfter);
100+
101+
// Cert Extensions
102+
certificateGenerator.AddExtension(
103+
X509Extensions.BasicConstraints,
104+
true,
105+
new BasicConstraints(true));
106+
certificateGenerator.AddExtension(
107+
X509Extensions.KeyUsage,
108+
true,
109+
new KeyUsage(KeyUsage.KeyCertSign | KeyUsage.CrlSign | KeyUsage.KeyEncipherment));
110+
111+
// Subject Public Key
112+
const int keyStrength = 256;
113+
var keyGenerator = new ECKeyPairGenerator("ECDSA");
114+
keyGenerator.Init(new KeyGenerationParameters(random, keyStrength));
115+
var key = keyGenerator.GenerateKeyPair();
116+
117+
certificateGenerator.SetPublicKey(key.Public);
118+
119+
var signatureFactory = new Asn1SignatureFactory("SHA512WITHECDSA", key.Private, random);
120+
var certificate = certificateGenerator.Generate(signatureFactory);
121+
122+
return (certificate, key);
123+
}
124+
125+
private static (X509Certificate Certificate, AsymmetricCipherKeyPair Key) CreateServerCertificate(
126+
(X509Certificate Certificate, AsymmetricCipherKeyPair Key) ca, string serverName, string serverNamespace)
127+
{
128+
var randomGenerator = new CryptoApiRandomGenerator();
129+
var random = new SecureRandom(randomGenerator);
130+
131+
// The Certificate Generator
132+
var certificateGenerator = new X509V3CertificateGenerator();
133+
134+
// Serial Number
135+
var serialNumber = BigIntegers.CreateRandomInRange(BigInteger.One, BigInteger.ValueOf(long.MaxValue), random);
136+
certificateGenerator.SetSerialNumber(serialNumber);
137+
138+
// Issuer and Subject Name
139+
certificateGenerator.SetIssuerDN(ca.Certificate.SubjectDN);
140+
certificateGenerator.SetSubjectDN(new X509Name("CN=Operator Service, C=DEV, L=Kubernetes"));
141+
142+
// Valid For
143+
var notBefore = DateTime.UtcNow.Date;
144+
var notAfter = notBefore.AddYears(5);
145+
certificateGenerator.SetNotBefore(notBefore);
146+
certificateGenerator.SetNotAfter(notAfter);
147+
148+
// Cert Extensions
149+
certificateGenerator.AddExtension(
150+
X509Extensions.BasicConstraints,
151+
false,
152+
new BasicConstraints(false));
153+
certificateGenerator.AddExtension(
154+
X509Extensions.KeyUsage,
155+
true,
156+
new KeyUsage(KeyUsage.NonRepudiation | KeyUsage.KeyEncipherment | KeyUsage.DigitalSignature));
157+
certificateGenerator.AddExtension(
158+
X509Extensions.ExtendedKeyUsage,
159+
false,
160+
new ExtendedKeyUsage(KeyPurposeID.id_kp_clientAuth, KeyPurposeID.id_kp_serverAuth));
161+
certificateGenerator.AddExtension(
162+
X509Extensions.SubjectKeyIdentifier,
163+
false,
164+
new SubjectKeyIdentifierStructure(ca.Key.Public));
165+
certificateGenerator.AddExtension(
166+
X509Extensions.AuthorityKeyIdentifier,
167+
false,
168+
new AuthorityKeyIdentifierStructure(ca.Certificate));
169+
certificateGenerator.AddExtension(
170+
X509Extensions.SubjectAlternativeName,
171+
false,
172+
new GeneralNames(new[]
173+
{
174+
new GeneralName(GeneralName.DnsName, $"{serverName}.{serverNamespace}.svc"),
175+
new GeneralName(GeneralName.DnsName, $"*.{serverNamespace}.svc"),
176+
new GeneralName(GeneralName.DnsName, "*.svc"),
177+
}));
178+
179+
// Subject Public Key
180+
const int keyStrength = 256;
181+
var keyGenerator = new ECKeyPairGenerator("ECDSA");
182+
keyGenerator.Init(new KeyGenerationParameters(random, keyStrength));
183+
var key = keyGenerator.GenerateKeyPair();
184+
185+
certificateGenerator.SetPublicKey(key.Public);
186+
187+
var signatureFactory = new Asn1SignatureFactory("SHA512WITHECDSA", ca.Key.Private, random);
188+
var certificate = certificateGenerator.Generate(signatureFactory);
189+
190+
return (certificate, key);
191+
}
192+
}

src/KubeOps.Cli/Commands/Generator/Generator.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ public static Command Command
1111
{
1212
var cmd = new Command("generator", "Generates elements related to an operator.")
1313
{
14+
CertificateGenerator.Command,
1415
CrdGenerator.Command,
1516
RbacGenerator.Command,
1617
};

src/KubeOps.Cli/KubeOps.Cli.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
</PropertyGroup>
1818

1919
<ItemGroup>
20+
<PackageReference Include="BouncyCastle.Cryptography" Version="2.2.1" />
2021
<PackageReference Include="KubernetesClient" Version="12.0.16"/>
2122
<PackageReference Include="Microsoft.Build.Locator" Version="1.6.10" />
2223
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.7.0"/>

src/KubeOps.Cli/Output/OutputFormat.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,9 @@ internal enum OutputFormat
1111
/// Format the output in Kubernetes JSON style.
1212
/// </summary>
1313
Json,
14+
15+
/// <summary>
16+
/// Format the output in plain text style.
17+
/// </summary>
18+
Plain,
1419
}

src/KubeOps.Cli/Output/ResultOutput.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ public void Write()
4949
{
5050
OutputFormat.Yaml => KubernetesYaml.Serialize(data),
5151
OutputFormat.Json => KubernetesJson.Serialize(data),
52+
OutputFormat.Plain => data.ToString() ?? string.Empty,
5253
_ => throw new ArgumentException("Unknown output format."),
5354
};
5455
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
using System.CommandLine;
2+
using System.CommandLine.Invocation;
3+
4+
using FluentAssertions;
5+
6+
using KubeOps.Cli.Commands.Generator;
7+
8+
using Org.BouncyCastle.Crypto;
9+
using Org.BouncyCastle.OpenSsl;
10+
using Org.BouncyCastle.X509;
11+
12+
using Spectre.Console.Testing;
13+
14+
namespace KubeOps.Cli.Test.Generator;
15+
16+
public class CertificateGeneratorTest
17+
{
18+
[Fact]
19+
public async Task Should_Execute()
20+
{
21+
var console = new TestConsole();
22+
23+
var cmd = CertificateGenerator.Command;
24+
var ctx = new InvocationContext(
25+
cmd.Parse("server", "namespace"));
26+
27+
await CertificateGenerator.Handler(console, ctx);
28+
29+
ctx.ExitCode.Should().Be(ExitCodes.Success);
30+
}
31+
32+
[Theory]
33+
[InlineData("ca.pem")]
34+
[InlineData("ca-key.pem")]
35+
[InlineData("svc.pem")]
36+
[InlineData("svc-key.pem")]
37+
public async Task Should_Generate_Certificate_Files(string file)
38+
{
39+
var console = new TestConsole();
40+
41+
var cmd = CertificateGenerator.Command;
42+
var ctx = new InvocationContext(
43+
cmd.Parse("server", "namespace"));
44+
45+
await CertificateGenerator.Handler(console, ctx);
46+
47+
console.Output.Should().Contain($"File: {file}");
48+
}
49+
50+
[Fact]
51+
public async Task Should_Generate_Valid_Certificates()
52+
{
53+
var console = new TestConsole();
54+
55+
var cmd = CertificateGenerator.Command;
56+
var ctx = new InvocationContext(
57+
cmd.Parse("server", "namespace"));
58+
59+
await CertificateGenerator.Handler(console, ctx);
60+
61+
var output = console.Lines.ToArray();
62+
var caCertString = string.Join('\n', output[4..15]);
63+
var caCertKeyString = string.Join('\n', output[18..23]);
64+
var srvCertString = string.Join('\n', output[26..42]);
65+
var srvCertKeyString = string.Join('\n', output[45..50]);
66+
67+
if (new PemReader(new StringReader(caCertString)).ReadObject() is not X509Certificate caCert)
68+
{
69+
Assert.Fail("Could not parse CA certificate.");
70+
return;
71+
}
72+
73+
if (new PemReader(new StringReader(caCertKeyString)).ReadObject() is not AsymmetricCipherKeyPair caKey)
74+
{
75+
Assert.Fail("Could not parse CA private key.");
76+
return;
77+
}
78+
79+
if (new PemReader(new StringReader(srvCertString)).ReadObject() is not X509Certificate srvCert)
80+
{
81+
Assert.Fail("Could not parse server certificate.");
82+
return;
83+
}
84+
85+
if (new PemReader(new StringReader(srvCertKeyString)).ReadObject() is not AsymmetricCipherKeyPair)
86+
{
87+
Assert.Fail("Could not parse server private key.");
88+
return;
89+
}
90+
91+
caCert.IsValidNow.Should().BeTrue();
92+
caCert.Verify(caKey.Public);
93+
94+
srvCert.IsValidNow.Should().BeTrue();
95+
srvCert.Verify(caKey.Public);
96+
}
97+
}

0 commit comments

Comments
 (0)