Skip to content

Commit

Permalink
Improved upgrade path for older TLS certificate generation versions.
Browse files Browse the repository at this point in the history
  • Loading branch information
Silvenga committed Nov 14, 2022
1 parent a0dfef9 commit 71f95a0
Show file tree
Hide file tree
Showing 7 changed files with 181 additions and 31 deletions.
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// Contrast Security, Inc licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information.

using System;
using System.IO;
using System.Security.Cryptography.X509Certificates;
using System.Text;
Expand All @@ -18,8 +17,6 @@ public interface ITlsCertificateChainConverter

public class TlsCertificateChainConverter : ITlsCertificateChainConverter
{
private const byte GenerationVersion = 2;

public TlsCertificateChainExport Export(TlsCertificateChain chain)
{
var caCertificatePem = chain.CaCertificate.Export(X509ContentType.Pkcs12);
Expand All @@ -29,22 +26,17 @@ public TlsCertificateChainExport Export(TlsCertificateChain chain)

var serverCertificatePem = chain.ServerCertificate.Export(X509ContentType.Pkcs12);

return new TlsCertificateChainExport(caCertificatePem, caPublicPem, serverCertificatePem, new[]{ GenerationVersion });
return new TlsCertificateChainExport(caCertificatePem, caPublicPem, serverCertificatePem, chain.Version);
}

public TlsCertificateChain Import(TlsCertificateChainExport export)
{
var (caCertificatePem, _, serverCertificatePem, version) = export;

if (version.Length != 1 || version[0] != GenerationVersion)
{
throw new Exception($"Incompatible generated version '{string.Join(",", version)}' detected, expected '{GenerationVersion}'.");
}

var caCertificate = new X509Certificate2(caCertificatePem, (string?)null, X509KeyStorageFlags.Exportable);
var serverCertificate = new X509Certificate2(serverCertificatePem, (string?)null, X509KeyStorageFlags.Exportable);

return new TlsCertificateChain(caCertificate, serverCertificate);
return new TlsCertificateChain(caCertificate, serverCertificate, version);
}

private static byte[] CreatePem(object o)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ public interface ITlsCertificateChainGenerator

public class TlsCertificateChainGenerator : ITlsCertificateChainGenerator
{
public static readonly byte[] GenerationVersion = { 3 };

private readonly CreateCertificates _createCertificates;
private readonly TlsCertificateOptions _options;

Expand All @@ -33,7 +35,7 @@ public TlsCertificateChain CreateTlsCertificateChain()
var ca = CreateRootCa(_options);
var serverCertificate = CreateServerCertificate(ca);

return new TlsCertificateChain(ca, serverCertificate);
return new TlsCertificateChain(ca, serverCertificate, GenerationVersion);
}

private X509Certificate2 CreateRootCa(TlsCertificateOptions options)
Expand Down Expand Up @@ -135,7 +137,7 @@ private X509Certificate2 CreateServerCertificate(X509Certificate2 signingCertifi
};
}

public record TlsCertificateChain(X509Certificate2 CaCertificate, X509Certificate2 ServerCertificate) : IDisposable
public record TlsCertificateChain(X509Certificate2 CaCertificate, X509Certificate2 ServerCertificate, byte[] Version) : IDisposable
{
public void Dispose()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Contrast Security, Inc licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information.

using System;
using System.Linq;

namespace Contrast.K8s.AgentOperator.Core.Tls
{
public interface ITlsCertificateChainValidator
{
bool IsValid(TlsCertificateChain chain, out ValidationResultReason reason);
}

public class TlsCertificateChainValidator : ITlsCertificateChainValidator
{
public bool IsValid(TlsCertificateChain chain, out ValidationResultReason reason)
{
var renewThreshold = DateTime.Now + TimeSpan.FromDays(90);

if (!chain.CaCertificate.HasPrivateKey
|| !chain.ServerCertificate.HasPrivateKey)
{
reason = ValidationResultReason.MissingPrivateKey;
}
else if (chain.CaCertificate.NotAfter < renewThreshold
|| chain.ServerCertificate.NotAfter < renewThreshold)
{
reason = ValidationResultReason.Expired;
}
else if (!TlsCertificateChainGenerator.GenerationVersion.SequenceEqual(chain.Version))
{
reason = ValidationResultReason.OldVersion;
}
else
{
reason = ValidationResultReason.NoError;
}

return reason == ValidationResultReason.NoError;
}
}

public enum ValidationResultReason
{
NoError = 0,
MissingPrivateKey,
Expired,
OldVersion
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,29 @@ public class TlsCertificateMaintenanceHandler : INotificationHandler<EntityRecon
private readonly ITlsCertificateChainConverter _certificateChainConverter;
private readonly IKubeWebHookConfigurationWriter _webHookConfigurationWriter;
private readonly IWebHookSecretParser _webHookSecretParser;
private readonly ITlsCertificateChainValidator _validator;

public TlsCertificateMaintenanceHandler(IKestrelCertificateSelector certificateSelector,
ITlsCertificateChainGenerator certificateChainGenerator,
ITlsCertificateChainConverter certificateChainConverter,
IKubeWebHookConfigurationWriter webHookConfigurationWriter,
IWebHookSecretParser webHookSecretParser)
IWebHookSecretParser webHookSecretParser,
ITlsCertificateChainValidator validator)
{
_certificateSelector = certificateSelector;
_certificateChainGenerator = certificateChainGenerator;
_certificateChainConverter = certificateChainConverter;
_webHookConfigurationWriter = webHookConfigurationWriter;
_webHookSecretParser = webHookSecretParser;
_validator = validator;
}

public Task Handle(EntityReconciled<V1Secret> notification, CancellationToken cancellationToken)
{
if (_webHookSecretParser.TryGetWebHookCertificateSecret(notification.Entity, out var chain))
{
// The certificate may not be valid, but that's not for us to figure out right now.
// At this point, we only care if the certificate was parseable.
if (_certificateSelector.TakeOwnershipOfCertificate(chain))
{
Logger.Info($"Secret '{notification.Entity.Namespace()}/{notification.Entity.Name()}' was changed, updated internal certificates.");
Expand All @@ -58,27 +63,31 @@ public async Task Handle(LeaderStateChanged notification, CancellationToken canc
if (notification.IsLeader)
{
var existingSecret = await _webHookConfigurationWriter.FetchCurrentCertificate();
if (existingSecret == null)
if (existingSecret == null
|| !_webHookSecretParser.TryGetWebHookCertificateSecret(existingSecret, out var chain))
{
// Missing.
await GenerateAndPublishCertificate();
}
else if (_webHookSecretParser.TryGetWebHookCertificateSecret(existingSecret, out var chain))
else
{
using (chain)
{
// Existing and valid, ensure web hook ca bundle is okay.
Logger.Info("Web hook certificate secret is valid.");
var chainExport = _certificateChainConverter.Export(chain);
await _webHookConfigurationWriter.UpdateClusterWebHookConfiguration(chainExport);
if (_validator.IsValid(chain, out var reason))
{
// Existing and valid, ensure web hook ca bundle is okay.
Logger.Info("Web hook certificate secret is valid.");
var chainExport = _certificateChainConverter.Export(chain);
await _webHookConfigurationWriter.UpdateClusterWebHookConfiguration(chainExport);
}
else
{
// Invalid.
Logger.Info($"Web hook certificate secret is invalid (Reason: '{reason}').");
await GenerateAndPublishCertificate();
}
}
}
else
{
// Invalid.
Logger.Info("Web hook certificate secret is invalid.");
await GenerateAndPublishCertificate();
}
}
}

Expand Down
12 changes: 9 additions & 3 deletions src/Contrast.K8s.AgentOperator/Core/Tls/WebHookSecretParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,18 @@ public bool TryGetWebHookCertificateSecret(V1Secret secret, [NotNullWhen(true)]
&& secret.Data != null
&& secret.Data.TryGetValue(_tlsStorageOptions.ServerCertificateName, out var serverCertificateBytes)
&& secret.Data.TryGetValue(_tlsStorageOptions.CaPublicName, out var caPublicBytes)
&& secret.Data.TryGetValue(_tlsStorageOptions.CaCertificateName, out var caCertificateBytes)
&& secret.Data.TryGetValue(_tlsStorageOptions.VersionName, out var versionBytes))
&& secret.Data.TryGetValue(_tlsStorageOptions.CaCertificateName, out var caCertificateBytes))
{
// Version is newer, so we might be upgrading from a version without versions.
var version = Array.Empty<byte>();
if (secret.Data.TryGetValue(_tlsStorageOptions.VersionName, out var versionBytes))
{
version = versionBytes;
}

try
{
chain = _certificateChainConverter.Import(new TlsCertificateChainExport(caCertificateBytes, caPublicBytes, serverCertificateBytes, versionBytes));
chain = _certificateChainConverter.Import(new TlsCertificateChainExport(caCertificateBytes, caPublicBytes, serverCertificateBytes, version));

var renewThreshold = DateTime.Now + TimeSpan.FromDays(90);
return chain.CaCertificate.HasPrivateKey
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Contrast Security, Inc licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information.

using System;
using AutoFixture;
using CertificateManager;
using Contrast.K8s.AgentOperator.Core.Tls;
using Contrast.K8s.AgentOperator.Options;
using FluentAssertions;
using FluentAssertions.Execution;
using Xunit;

namespace Contrast.K8s.AgentOperator.Tests.Core.Tls
{
public class TlsCertificateChainValidatorTests
{
private static readonly Fixture AutoFixture = new();

[Fact]
public void When_chain_expiration_is_valid_then_IsValid_should_return_true()
{
using var chainFake = FakeCertificates(TimeSpan.FromDays(180));

var validator = new TlsCertificateChainValidator();

// Act
var result = validator.IsValid(chainFake, out _);

// Assert
result.Should().BeTrue();
}

[Fact]
public void When_chain_expiration_is_expired_then_IsValid_should_return_expired()
{
using var chainFake = FakeCertificates(TimeSpan.FromDays(45));

var validator = new TlsCertificateChainValidator();

// Act
var result = validator.IsValid(chainFake, out var reason);

// Assert
using (new AssertionScope())
{
result.Should().BeFalse();
reason.Should().Be(ValidationResultReason.Expired);
}
}

[Fact]
public void When_chain_version_differs_then_IsValid_should_return_old_version()
{
using var chainFake = FakeCertificates(TimeSpan.FromDays(180)) with
{
Version = AutoFixture.Create<byte[]>()
};

var validator = new TlsCertificateChainValidator();

// Act
var result = validator.IsValid(chainFake, out var reason);

// Assert
using (new AssertionScope())
{
result.Should().BeFalse();
reason.Should().Be(ValidationResultReason.OldVersion);
}
}

private static TlsCertificateChain FakeCertificates(TimeSpan expiresAfter)
{
var generator = new TlsCertificateChainGenerator(
new CreateCertificates(new CertificateUtility()),
AutoFixture.Create<TlsCertificateOptions>() with { ExpiresAfter = expiresAfter }
);
return generator.CreateTlsCertificateChain();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,18 @@ public async Task When_leader_elected_and_secret_is_valid_then_cluster_web_hook_
var converterMock = Substitute.For<ITlsCertificateChainConverter>();
converterMock.Export(chainFake).Returns(exportFake);

var validatorMock = Substitute.For<ITlsCertificateChainValidator>();
validatorMock.IsValid(Arg.Is(chainFake), out _).Returns(info =>
{
info[1] = ValidationResultReason.NoError;
return true;
});

var handler = CreateGraph(
webHookSecretParser: parserMock,
webHookConfigurationWriter: writerMock,
certificateChainConverter: converterMock
certificateChainConverter: converterMock,
certificateChainValidator:validatorMock
);

// Act
Expand Down Expand Up @@ -143,14 +151,16 @@ private static TlsCertificateMaintenanceHandler CreateGraph(IKestrelCertificateS
ITlsCertificateChainGenerator? certificateChainGenerator = null,
ITlsCertificateChainConverter? certificateChainConverter = null,
IKubeWebHookConfigurationWriter? webHookConfigurationWriter = null,
IWebHookSecretParser? webHookSecretParser = null)
IWebHookSecretParser? webHookSecretParser = null,
ITlsCertificateChainValidator? certificateChainValidator = null)
{
return new TlsCertificateMaintenanceHandler(
certificateSelector ?? Substitute.For<IKestrelCertificateSelector>(),
certificateChainGenerator ?? Substitute.For<ITlsCertificateChainGenerator>(),
certificateChainConverter ?? Substitute.For<ITlsCertificateChainConverter>(),
webHookConfigurationWriter ?? Substitute.For<IKubeWebHookConfigurationWriter>(),
webHookSecretParser ?? Substitute.For<IWebHookSecretParser>()
webHookSecretParser ?? Substitute.For<IWebHookSecretParser>(),
certificateChainValidator ?? Substitute.For<ITlsCertificateChainValidator>()
);
}

Expand Down

0 comments on commit 71f95a0

Please sign in to comment.