From cf4ec867f1922e0daf568e7898ede23ff81c9f23 Mon Sep 17 00:00:00 2001 From: robert Date: Fri, 14 Aug 2026 00:05:43 +1000 Subject: [PATCH 1/2] Add BouncyCastle-based native keystore builder for the Java Keystore step Replaces the com.octopus.calamari.keystore.KeystoreConfig Java process with a native BouncyCastle.Cryptography implementation, gated behind the java-keystore-native-bouncycastle feature toggle so the existing Java-based path remains the default. Builds a PKCS12 keystore rather than JKS (the original's JKS support comes from the JDK's own provider, which has no .NET equivalent; BouncyCastle there is PEM-parsing only). PKCS12 is accepted anywhere JKS currently is, provided the consuming Tomcat/WildFly config declares the keystore type explicitly. --- .../FeatureToggles/OctopusFeatureToggle.cs | 2 + .../Certificates/Java/JavaKeystoreBuilder.cs | 242 ++++++++++++++++++ .../Fixtures/JavaKeystoreActionFixture.cs | 117 +++++++++ .../Fixtures/JavaKeystoreBuilderFixture.cs | 178 +++++++++++++ .../Commands/Java/JavaLibraryCommand.cs | 11 +- .../Java/Actions/JavaKeystoreAction.cs | 31 ++- 6 files changed, 572 insertions(+), 9 deletions(-) create mode 100644 source/Calamari.Shared/Integration/Certificates/Java/JavaKeystoreBuilder.cs create mode 100644 source/Calamari.Tests/Java/Fixtures/JavaKeystoreActionFixture.cs create mode 100644 source/Calamari.Tests/Java/Fixtures/JavaKeystoreBuilderFixture.cs diff --git a/source/Calamari.Common/FeatureToggles/OctopusFeatureToggle.cs b/source/Calamari.Common/FeatureToggles/OctopusFeatureToggle.cs index f0066c6da8..988d7e61c2 100644 --- a/source/Calamari.Common/FeatureToggles/OctopusFeatureToggle.cs +++ b/source/Calamari.Common/FeatureToggles/OctopusFeatureToggle.cs @@ -12,6 +12,7 @@ public static class KnownSlugs public const string EnableLegacyKubernetesResourceChecks = "enable-legacy-kubernetes-resource-checks"; public const string AzureWebAppIgnorePreservePathsFeatureToggle = "azure-web-app-ignore-preserve-paths"; public const string AzureWebAppIgnoreChecksumFeatureToggle = "azure-web-app-ignore-checksum"; + public const string JavaKeystoreNativeBouncyCastle = "java-keystore-native-bouncycastle"; }; public static readonly OctopusFeatureToggle KustomizePatchImageUpdatesFeatureToggle = new(KnownSlugs.KustomizePatchImageUpdatesFeatureToggle); @@ -20,6 +21,7 @@ public static class KnownSlugs public static readonly OctopusFeatureToggle EnableLegacyKubernetesResourceChecksFeatureToggle = new(KnownSlugs.EnableLegacyKubernetesResourceChecks); public static readonly OctopusFeatureToggle AzureWebAppIgnorePreservePathsFeatureToggle = new(KnownSlugs.AzureWebAppIgnorePreservePathsFeatureToggle); public static readonly OctopusFeatureToggle AzureWebAppIgnoreChecksumFeatureToggle = new(KnownSlugs.AzureWebAppIgnoreChecksumFeatureToggle); + public static readonly OctopusFeatureToggle JavaKeystoreNativeBouncyCastleFeatureToggle = new(KnownSlugs.JavaKeystoreNativeBouncyCastle); public class OctopusFeatureToggle { diff --git a/source/Calamari.Shared/Integration/Certificates/Java/JavaKeystoreBuilder.cs b/source/Calamari.Shared/Integration/Certificates/Java/JavaKeystoreBuilder.cs new file mode 100644 index 0000000000..5da1d0b4c2 --- /dev/null +++ b/source/Calamari.Shared/Integration/Certificates/Java/JavaKeystoreBuilder.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Calamari.Common.Commands; +using Calamari.Common.Plumbing.Logging; +using Org.BouncyCastle.Asn1.Sec; +using Org.BouncyCastle.Asn1.X9; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.OpenSsl; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Org.BouncyCastle.X509; +using Polly; +using Polly.Retry; + +namespace Calamari.Integration.Certificates.Java +{ + /// + /// Builds a PKCS#12 keystore from a PEM-encoded private key and certificate chain, using + /// BouncyCastle.Cryptography, without requiring a JVM or the Octopus.Dependencies.Java tool + /// package. + /// + /// This replicates the behaviour of the "Deploy a Keystore to the Filesystem" step's existing + /// implementation (com.octopus.calamari.keystore.KeystoreConfig, in the Octopus.Dependencies.Java + /// repository), with one deliberate difference: that implementation builds a JKS keystore via the + /// JDK's own built-in security provider (BouncyCastle there is only used to parse the incoming PEM + /// material) - JKS has no equivalent writer in .NET, so this produces a PKCS#12 keystore instead. + /// PKCS#12 is accepted anywhere JKS currently is, provided the consuming Tomcat/WildFly + /// configuration is told the keystore type explicitly (both currently rely on an unstated JKS + /// default). + /// + public class JavaKeystoreBuilder + { + public const string DefaultAlias = "octopus"; + public const string DefaultPassword = "changeit"; + + static readonly Regex BeginPrivateKey = new Regex(@"-+BEGIN\s+.*PRIVATE\s+KEY", RegexOptions.IgnoreCase); + + readonly ILog log; + + public JavaKeystoreBuilder(ILog log) + { + this.log = log; + } + + /// + /// Builds a PKCS#12 store in memory containing the given private key and certificate chain + /// under the given alias, protected by the given password. The key entry's password and the + /// store's own password are always the same value - Tomcat, in particular, does not support a + /// mismatch between the two. + /// + public Pkcs12Store BuildPkcs12Store(string alias, string privateKeyPem, string certificateChainPem, string password) + { + var fixedAlias = string.IsNullOrWhiteSpace(alias) ? DefaultAlias : alias; + var fixedPassword = string.IsNullOrWhiteSpace(password) ? DefaultPassword : password; + + var privateKey = ParsePrivateKey(privateKeyPem); + var certificateChain = ParseCertificateChain(certificateChainPem); + + var store = new Pkcs12StoreBuilder().Build(); + var certificateEntries = certificateChain.Select(c => new X509CertificateEntry(c)).ToArray(); + store.SetKeyEntry(fixedAlias, new AsymmetricKeyEntry(privateKey), certificateEntries); + + return store; + } + + /// + /// Builds a PKCS#12 store (see ) and writes it to disk, retrying + /// on failure with an exponential backoff (matching the retry policy used by the existing + /// Java-based implementation: 5 attempts total, starting at 5 seconds and doubling). + /// + /// The absolute path the keystore was written to. + public string SaveKeystoreToFile(string alias, string privateKeyPem, string certificateChainPem, string password, string keystoreFilename) + { + if (string.IsNullOrWhiteSpace(keystoreFilename)) + throw new CommandException("The keystore filename must be supplied."); + + if (!Path.IsPathRooted(keystoreFilename)) + throw new CommandException("The keystore filename must be an absolute path."); + + var fixedPassword = string.IsNullOrWhiteSpace(password) ? DefaultPassword : password; + + // Parsing the key/certificate is deterministic - a malformed PEM will fail exactly the + // same way on every attempt, so it's built once, outside the retry pipeline below. Only + // the file write itself (which can hit transient permission/disk contention) is retried. + var store = BuildPkcs12Store(alias, privateKeyPem, certificateChainPem, fixedPassword); + + CreateRetryPipeline().Execute(() => + { + using (var fileStream = new FileStream(keystoreFilename, FileMode.Create)) + { + store.Save(fileStream, fixedPassword.ToCharArray(), new SecureRandom()); + } + + // The save operation may not fail even if something prevented the file from actually + // being created, so check for its existence explicitly. + if (!File.Exists(keystoreFilename)) + throw new CommandException($"File was not created at {keystoreFilename}"); + + log.Verbose($"Successfully created keystore at {keystoreFilename}"); + }); + + return Path.GetFullPath(keystoreFilename); + } + + ResiliencePipeline CreateRetryPipeline() + { + return new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + ShouldHandle = new PredicateBuilder().Handle(), + MaxRetryAttempts = 4, + Delay = TimeSpan.FromSeconds(5), + BackoffType = DelayBackoffType.Exponential, + OnRetry = args => + { + log.Verbose($"Failed to create the keystore file, waiting {args.RetryDelay.TotalSeconds}s before trying again. {args.Outcome.Exception?.Message}"); + return default; + } + }) + .Build(); + } + + /// + /// Returns "RSA", "EC" or "DSA" for the given PEM-encoded private key - used by callers (such + /// as the Tomcat certificate configurator) that need to know the key's algorithm to write it + /// into a config file correctly, without exposing BouncyCastle's own key-parameter types. + /// + public static string GetPrivateKeyAlgorithm(string privateKeyPem) + { + var key = ParsePrivateKey(privateKeyPem); + switch (key) + { + case ECPrivateKeyParameters _: + return "EC"; + case DsaPrivateKeyParameters _: + return "DSA"; + case RsaKeyParameters _: + return "RSA"; + default: + throw new CommandException($"Unrecognised private key algorithm: {key.GetType().Name}."); + } + } + + static AsymmetricKeyParameter ParsePrivateKey(string pem) + { + var match = BeginPrivateKey.Match(pem); + if (!match.Success) + throw new CommandException("The private key does not contain a recognisable PEM private key block."); + + var trimmed = pem.Substring(match.Index); + + object pemObject; + try + { + using (var reader = new StringReader(trimmed)) + { + pemObject = new PemReader(reader).ReadObject(); + } + } + catch (Exception ex) + { + // BouncyCastle's PemReader expects an EC private key to carry its public point + // alongside it. Octopus sometimes supplies EC private keys in isolation, so fall back + // to manually parsing the SEC1 structure when the standard parser can't handle it. + var fallback = TryParseEcPrivateKeyWithoutPublicPoint(trimmed); + if (fallback == null) + throw new CommandException("Unable to parse the private key.", ex); + + return fallback; + } + + switch (pemObject) + { + case AsymmetricCipherKeyPair keyPair: + return keyPair.Private; + case AsymmetricKeyParameter keyParameter: + return keyParameter; + default: + throw new CommandException($"Unrecognised private key format: {pemObject?.GetType().Name ?? "null"}."); + } + } + + static AsymmetricKeyParameter? TryParseEcPrivateKeyWithoutPublicPoint(string pem) + { + if (!pem.Contains("EC PRIVATE KEY")) + return null; + + try + { + using (var reader = new StringReader(pem)) + { + var pemObject = new Org.BouncyCastle.Utilities.IO.Pem.PemReader(reader).ReadPemObject(); + var ecPrivateKeyStructure = ECPrivateKeyStructure.GetInstance(pemObject.Content); + + var parametersObject = ecPrivateKeyStructure.GetParameters(); + var curveParameters = parametersObject is Org.BouncyCastle.Asn1.DerObjectIdentifier namedCurveOid + ? Org.BouncyCastle.Asn1.X9.ECNamedCurveTable.GetByOid(namedCurveOid) + : Org.BouncyCastle.Asn1.X9.X9ECParameters.GetInstance(parametersObject); + + return new ECPrivateKeyParameters( + ecPrivateKeyStructure.GetKey(), + new Org.BouncyCastle.Crypto.Parameters.ECDomainParameters(curveParameters.Curve, curveParameters.G, curveParameters.N, curveParameters.H, curveParameters.GetSeed())); + } + } + catch + { + return null; + } + } + + static IList ParseCertificateChain(string pem) + { + var certificates = new List(); + + // The certificate PEM may contain a chain (leaf followed by one or more intermediates) + // concatenated together. X509CertificateParser.ReadCertificate can be called repeatedly + // against the same stream to read each one in turn until the stream is exhausted. + using (var stream = new MemoryStream(Encoding.ASCII.GetBytes(pem))) + { + var parser = new X509CertificateParser(); + while (true) + { + var certificate = parser.ReadCertificate(stream); + if (certificate == null) + break; + + certificates.Add(certificate); + } + } + + if (!certificates.Any()) + throw new CommandException("Certificate file does not contain any certificates. This is probably because the input certificate file is invalid."); + + return certificates; + } + } +} diff --git a/source/Calamari.Tests/Java/Fixtures/JavaKeystoreActionFixture.cs b/source/Calamari.Tests/Java/Fixtures/JavaKeystoreActionFixture.cs new file mode 100644 index 0000000000..b017f73194 --- /dev/null +++ b/source/Calamari.Tests/Java/Fixtures/JavaKeystoreActionFixture.cs @@ -0,0 +1,117 @@ +using System.IO; +using Calamari.Common.Commands; +using Calamari.Common.FeatureToggles; +using Calamari.Common.Features.Processes; +using Calamari.Common.Plumbing.Variables; +using Calamari.Deployment; +using Calamari.Deployment.Features.Java; +using Calamari.Deployment.Features.Java.Actions; +using Calamari.Testing.Helpers; +using FluentAssertions; +using NSubstitute; +using NUnit.Framework; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.OpenSsl; +using Org.BouncyCastle.Security; + +namespace Calamari.Tests.Java.Fixtures +{ + [TestFixture] + public class JavaKeystoreActionFixture + { + const string CertificateVariableName = "MyCertificate"; + + ICommandLineRunner commandLineRunner; + InMemoryLog log; + string workingDirectory; + string privateKeyPem; + string certificatePem; + + [SetUp] + public void SetUp() + { + commandLineRunner = Substitute.For(); + commandLineRunner.Execute(Arg.Any()).Returns(new CommandResult("", 0)); + log = new InMemoryLog(); + workingDirectory = Path.Combine(Path.GetTempPath(), "JavaKeystoreActionFixture-" + System.Guid.NewGuid()); + Directory.CreateDirectory(workingDirectory); + + var keyGenerator = new RsaKeyPairGenerator(); + keyGenerator.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + var keyPair = keyGenerator.GenerateKeyPair(); + + var generator = new Org.BouncyCastle.X509.X509V3CertificateGenerator(); + var subject = new Org.BouncyCastle.Asn1.X509.X509Name("CN=octopus-action-test"); + generator.SetSerialNumber(Org.BouncyCastle.Math.BigInteger.ValueOf(System.DateTime.UtcNow.Ticks)); + generator.SetIssuerDN(subject); + generator.SetSubjectDN(subject); + generator.SetNotBefore(System.DateTime.UtcNow.AddDays(-1)); + generator.SetNotAfter(System.DateTime.UtcNow.AddYears(1)); + generator.SetPublicKey(keyPair.Public); + var certificate = generator.Generate(new Org.BouncyCastle.Crypto.Operators.Asn1SignatureFactory("SHA256WITHRSA", keyPair.Private)); + + using (var writer = new StringWriter()) + { + new PemWriter(writer).WriteObject(keyPair.Private); + privateKeyPem = writer.ToString(); + } + + using (var writer = new StringWriter()) + { + new PemWriter(writer).WriteObject(certificate); + certificatePem = writer.ToString(); + } + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(workingDirectory)) + Directory.Delete(workingDirectory, true); + } + + RunningDeployment BuildDeployment(bool nativeToggleEnabled, string keystoreFilename) + { + var variables = new CalamariVariables(); + variables.Set(SpecialVariables.Action.Java.JavaKeystore.Variable, CertificateVariableName); + variables.Set(SpecialVariables.Action.Java.JavaKeystore.Password, "sekret"); + variables.Set(SpecialVariables.Action.Java.JavaKeystore.KeystoreFilename, keystoreFilename); + variables.Set(SpecialVariables.Action.Java.JavaKeystore.KeystoreAlias, "myalias"); + variables.Set(SpecialVariables.Certificate.PrivateKeyPem(CertificateVariableName), privateKeyPem); + variables.Set(SpecialVariables.Certificate.CertificatePem(CertificateVariableName), certificatePem); + variables.Set(SpecialVariables.Certificate.Subject(CertificateVariableName), "CN=octopus-action-test"); + + if (nativeToggleEnabled) + variables.Set(KnownVariables.EnabledFeatureToggles, OctopusFeatureToggles.KnownSlugs.JavaKeystoreNativeBouncyCastle); + + return new RunningDeployment(variables); + } + + [Test] + public void Execute_WhenNativeToggleEnabled_WritesKeystoreWithoutInvokingJava() + { + var keystorePath = Path.Combine(workingDirectory, "test.p12"); + var deployment = BuildDeployment(nativeToggleEnabled: true, keystoreFilename: keystorePath); + var action = new JavaKeystoreAction(new JavaRunner(commandLineRunner, deployment.Variables), log); + + action.Execute(deployment); + + File.Exists(keystorePath).Should().BeTrue(); + commandLineRunner.DidNotReceive().Execute(Arg.Any()); + } + + [Test] + public void Execute_WhenNativeToggleDisabled_FallsBackToInvokingCalamariJar() + { + var keystorePath = Path.Combine(workingDirectory, "test.p12"); + var deployment = BuildDeployment(nativeToggleEnabled: false, keystoreFilename: keystorePath); + var action = new JavaKeystoreAction(new JavaRunner(commandLineRunner, deployment.Variables), log); + + action.Execute(deployment); + + File.Exists(keystorePath).Should().BeFalse("the legacy path only writes the keystore via the mocked-out java process, which doesn't actually run"); + commandLineRunner.Received().Execute(Arg.Is(i => i.Arguments.Contains("com.octopus.calamari.keystore.KeystoreConfig"))); + } + } +} diff --git a/source/Calamari.Tests/Java/Fixtures/JavaKeystoreBuilderFixture.cs b/source/Calamari.Tests/Java/Fixtures/JavaKeystoreBuilderFixture.cs new file mode 100644 index 0000000000..f5d4308940 --- /dev/null +++ b/source/Calamari.Tests/Java/Fixtures/JavaKeystoreBuilderFixture.cs @@ -0,0 +1,178 @@ +using System; +using System.IO; +using Calamari.Common.Commands; +using Calamari.Common.Plumbing.Logging; +using Calamari.Integration.Certificates.Java; +using Calamari.Testing.Helpers; +using FluentAssertions; +using NUnit.Framework; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.OpenSsl; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Org.BouncyCastle.X509; +using X509Certificate = Org.BouncyCastle.X509.X509Certificate; + +namespace Calamari.Tests.Java.Fixtures +{ + [TestFixture] + public class JavaKeystoreBuilderFixture + { + InMemoryLog log; + string workingDirectory; + + [SetUp] + public void SetUp() + { + log = new InMemoryLog(); + workingDirectory = Path.Combine(Path.GetTempPath(), "JavaKeystoreBuilderFixture-" + Guid.NewGuid()); + Directory.CreateDirectory(workingDirectory); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(workingDirectory)) + Directory.Delete(workingDirectory, true); + } + + [Test] + public void SaveKeystoreToFile_WithRsaKeyAndSingleCertificate_ProducesAValidPkcs12KeystoreContainingTheSameKeyAndCertificate() + { + var (privateKeyPem, certificatePem, keyPair, certificate) = CreateSelfSignedRsaCertificate("CN=octopus-test"); + + var keystorePath = Path.Combine(workingDirectory, "test.p12"); + var builder = new JavaKeystoreBuilder(log); + + var resultPath = builder.SaveKeystoreToFile("myalias", privateKeyPem, certificatePem, "sekret", keystorePath); + + resultPath.Should().Be(Path.GetFullPath(keystorePath)); + File.Exists(keystorePath).Should().BeTrue(); + + var reloaded = new Pkcs12StoreBuilder().Build(); + using (var fileStream = new FileStream(keystorePath, FileMode.Open)) + { + reloaded.Load(fileStream, "sekret".ToCharArray()); + } + + reloaded.ContainsAlias("myalias").Should().BeTrue(); + reloaded.IsKeyEntry("myalias").Should().BeTrue(); + + var reloadedChain = reloaded.GetCertificateChain("myalias"); + reloadedChain.Should().HaveCount(1); + reloadedChain[0].Certificate.CertificateStructure.Should().Be(certificate.CertificateStructure); + + var reloadedKey = reloaded.GetKey("myalias").Key; + reloadedKey.Should().Be(keyPair.Private); + } + + [Test] + public void SaveKeystoreToFile_WithBlankAliasAndPassword_FallsBackToOctopusDefaults() + { + var (privateKeyPem, certificatePem, _, _) = CreateSelfSignedRsaCertificate("CN=octopus-defaults-test"); + + var keystorePath = Path.Combine(workingDirectory, "defaults.p12"); + var builder = new JavaKeystoreBuilder(log); + + builder.SaveKeystoreToFile("", privateKeyPem, certificatePem, "", keystorePath); + + var reloaded = new Pkcs12StoreBuilder().Build(); + using (var fileStream = new FileStream(keystorePath, FileMode.Open)) + { + reloaded.Load(fileStream, JavaKeystoreBuilder.DefaultPassword.ToCharArray()); + } + + reloaded.ContainsAlias(JavaKeystoreBuilder.DefaultAlias).Should().BeTrue(); + } + + [Test] + public void SaveKeystoreToFile_WithRelativeKeystorePath_ThrowsCommandException() + { + var (privateKeyPem, certificatePem, _, _) = CreateSelfSignedRsaCertificate("CN=octopus-relative-path-test"); + var builder = new JavaKeystoreBuilder(log); + + Action act = () => builder.SaveKeystoreToFile("myalias", privateKeyPem, certificatePem, "sekret", "relative/path.p12"); + + act.Should().Throw().WithMessage("*absolute path*"); + } + + [Test] + public void SaveKeystoreToFile_WithNoCertificatesInPem_ThrowsCommandException() + { + var (privateKeyPem, _, _, _) = CreateSelfSignedRsaCertificate("CN=octopus-no-cert-test"); + var builder = new JavaKeystoreBuilder(log); + var keystorePath = Path.Combine(workingDirectory, "no-cert.p12"); + + Action act = () => builder.SaveKeystoreToFile("myalias", privateKeyPem, "not a certificate", "sekret", keystorePath); + + act.Should().Throw().WithMessage("*does not contain any certificates*"); + } + + [Test] + public void BuildPkcs12Store_WithCertificateChain_IncludesEveryCertificateInOrder() + { + // A genuine chain, not two unrelated self-signed certificates: the intermediate is its + // own CA, and the leaf is actually issued by (signed by) that intermediate. + var (_, intermediateCertPem, intermediateKeyPair, intermediateCert) = CreateSelfSignedRsaCertificate("CN=intermediate"); + var (leafKeyPem, leafCertPem, _, leafCert) = CreateRsaCertificate("CN=leaf", "CN=intermediate", intermediateKeyPair); + + var builder = new JavaKeystoreBuilder(log); + var store = builder.BuildPkcs12Store("myalias", leafKeyPem, leafCertPem + "\n" + intermediateCertPem, "sekret"); + + var chain = store.GetCertificateChain("myalias"); + chain.Should().HaveCount(2); + chain[0].Certificate.CertificateStructure.Should().Be(leafCert.CertificateStructure); + chain[1].Certificate.CertificateStructure.Should().Be(intermediateCert.CertificateStructure); + } + + static (string PrivateKeyPem, string CertificatePem, AsymmetricCipherKeyPair KeyPair, X509Certificate Certificate) CreateSelfSignedRsaCertificate(string subjectDn) + { + var keyGenerator = new RsaKeyPairGenerator(); + keyGenerator.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + var keyPair = keyGenerator.GenerateKeyPair(); + return CreateRsaCertificate(subjectDn, subjectDn, keyPair, keyPair); + } + + static (string PrivateKeyPem, string CertificatePem, AsymmetricCipherKeyPair KeyPair, X509Certificate Certificate) CreateRsaCertificate(string subjectDn, string issuerDn, AsymmetricCipherKeyPair issuerKeyPair) + { + var keyGenerator = new RsaKeyPairGenerator(); + keyGenerator.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + var keyPair = keyGenerator.GenerateKeyPair(); + return CreateRsaCertificate(subjectDn, issuerDn, keyPair, issuerKeyPair); + } + + static (string PrivateKeyPem, string CertificatePem, AsymmetricCipherKeyPair KeyPair, X509Certificate Certificate) CreateRsaCertificate(string subjectDn, string issuerDn, AsymmetricCipherKeyPair keyPair, AsymmetricCipherKeyPair issuerKeyPair) + { + var signingKeyPair = issuerKeyPair; + + var generator = new X509V3CertificateGenerator(); + generator.SetSerialNumber(Org.BouncyCastle.Math.BigInteger.ValueOf(DateTime.UtcNow.Ticks)); + generator.SetIssuerDN(new Org.BouncyCastle.Asn1.X509.X509Name(issuerDn)); + generator.SetSubjectDN(new Org.BouncyCastle.Asn1.X509.X509Name(subjectDn)); + generator.SetNotBefore(DateTime.UtcNow.AddDays(-1)); + generator.SetNotAfter(DateTime.UtcNow.AddYears(1)); + generator.SetPublicKey(keyPair.Public); + + var signatureFactory = new Org.BouncyCastle.Crypto.Operators.Asn1SignatureFactory("SHA256WITHRSA", signingKeyPair.Private); + var certificate = generator.Generate(signatureFactory); + + string privateKeyPem; + using (var writer = new StringWriter()) + { + new PemWriter(writer).WriteObject(keyPair.Private); + privateKeyPem = writer.ToString(); + } + + string certificatePem; + using (var writer = new StringWriter()) + { + new PemWriter(writer).WriteObject(certificate); + certificatePem = writer.ToString(); + } + + return (privateKeyPem, certificatePem, keyPair, certificate); + } + } +} diff --git a/source/Calamari/Commands/Java/JavaLibraryCommand.cs b/source/Calamari/Commands/Java/JavaLibraryCommand.cs index 143034aec4..d9ba89bb17 100644 --- a/source/Calamari/Commands/Java/JavaLibraryCommand.cs +++ b/source/Calamari/Commands/Java/JavaLibraryCommand.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using Calamari.Commands.Support; using Calamari.Common.Commands; +using Calamari.Common.FeatureToggles; using Calamari.Common.Features.Deployment; using Calamari.Common.Features.EmbeddedResources; using Calamari.Common.Features.Packages.Java; @@ -38,7 +39,15 @@ public JavaLibraryCommand(IScriptEngine scriptEngine, ICalamariFileSystem fileSy public override int Execute(string[] commandLineArguments) { Options.Parse(commandLineArguments); - JavaRuntime.VerifyExists(); + + // The native BouncyCastle-based keystore step doesn't need a JVM at all. Every other + // action type this command dispatches to (Tomcat/WildFly state and certificate steps) + // still shells out to calamari.jar, so the check only skips for that one action type. + var isNativeKeystoreStep = actionType == SpecialVariables.Action.Java.JavaKeystore.CertificateActionTypeName + && OctopusFeatureToggles.JavaKeystoreNativeBouncyCastleFeatureToggle.IsEnabled(variables); + + if (!isNativeKeystoreStep) + JavaRuntime.VerifyExists(); var embeddedResources = new AssemblyEmbeddedResources(); diff --git a/source/Calamari/Deployment/Features/Java/Actions/JavaKeystoreAction.cs b/source/Calamari/Deployment/Features/Java/Actions/JavaKeystoreAction.cs index 0e5fe36b33..392f4525af 100644 --- a/source/Calamari/Deployment/Features/Java/Actions/JavaKeystoreAction.cs +++ b/source/Calamari/Deployment/Features/Java/Actions/JavaKeystoreAction.cs @@ -1,7 +1,9 @@ using System.Collections.Generic; using Calamari.Commands.Java; using Calamari.Common.Commands; +using Calamari.Common.FeatureToggles; using Calamari.Common.Plumbing.Logging; +using Calamari.Integration.Certificates.Java; namespace Calamari.Deployment.Features.Java.Actions { @@ -17,18 +19,31 @@ public override void Execute(RunningDeployment deployment) { var variables = deployment.Variables; log.Info("Adding certificate to Java Keystore"); - + var certificateId = variables.Get(SpecialVariables.Action.Java.JavaKeystore.Variable); + var password = variables.Get(SpecialVariables.Action.Java.JavaKeystore.Password); + var keystoreFilename = variables.Get(SpecialVariables.Action.Java.JavaKeystore.KeystoreFilename); + var keystoreAlias = variables.Get(SpecialVariables.Action.Java.JavaKeystore.KeystoreAlias); + var privateKeyPem = variables.Get(SpecialVariables.Certificate.PrivateKeyPem(certificateId)); + var certificatePem = variables.Get(SpecialVariables.Certificate.CertificatePem(certificateId)); + + if (OctopusFeatureToggles.JavaKeystoreNativeBouncyCastleFeatureToggle.IsEnabled(variables)) + { + var keystorePath = new JavaKeystoreBuilder(log).SaveKeystoreToFile(keystoreAlias, privateKeyPem, certificatePem, password, keystoreFilename); + log.Info($"Keystore was successfully deployed to \"{keystorePath}\"."); + return; + } + var envVariables = new Dictionary(){ - {"OctopusEnvironment_Java_Certificate_Variable", certificateId}, - {"OctopusEnvironment_Java_Certificate_Password", variables.Get(SpecialVariables.Action.Java.JavaKeystore.Password)}, - {"OctopusEnvironment_Java_Certificate_KeystoreFilename", variables.Get(SpecialVariables.Action.Java.JavaKeystore.KeystoreFilename)}, - {"OctopusEnvironment_Java_Certificate_KeystoreAlias", variables.Get(SpecialVariables.Action.Java.JavaKeystore.KeystoreAlias)}, - {"OctopusEnvironment_Java_Certificate_Private_Key", variables.Get(SpecialVariables.Certificate.PrivateKeyPem(certificateId))}, - {"OctopusEnvironment_Java_Certificate_Public_Key", variables.Get(SpecialVariables.Certificate.CertificatePem(certificateId))}, + {"OctopusEnvironment_Java_Certificate_Variable", certificateId}, + {"OctopusEnvironment_Java_Certificate_Password", password}, + {"OctopusEnvironment_Java_Certificate_KeystoreFilename", keystoreFilename}, + {"OctopusEnvironment_Java_Certificate_KeystoreAlias", keystoreAlias}, + {"OctopusEnvironment_Java_Certificate_Private_Key", privateKeyPem}, + {"OctopusEnvironment_Java_Certificate_Public_Key", certificatePem}, {"OctopusEnvironment_Java_Certificate_Public_Key_Subject", variables.Get(SpecialVariables.Certificate.Subject(certificateId))}, }; runner.Run("com.octopus.calamari.keystore.KeystoreConfig", envVariables); } } -} \ No newline at end of file +} From 129b6120cea010320ad31285d9de4b8418126dfc Mon Sep 17 00:00:00 2001 From: robert Date: Fri, 14 Aug 2026 09:32:43 +1000 Subject: [PATCH 2/2] Update some test strings to be explicitly random and not passwords --- .../Java/Fixtures/JavaKeystoreActionFixture.cs | 2 +- .../Java/Fixtures/JavaKeystoreBuilderFixture.cs | 14 ++++++-------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/source/Calamari.Tests/Java/Fixtures/JavaKeystoreActionFixture.cs b/source/Calamari.Tests/Java/Fixtures/JavaKeystoreActionFixture.cs index b017f73194..1959ca070f 100644 --- a/source/Calamari.Tests/Java/Fixtures/JavaKeystoreActionFixture.cs +++ b/source/Calamari.Tests/Java/Fixtures/JavaKeystoreActionFixture.cs @@ -75,7 +75,7 @@ RunningDeployment BuildDeployment(bool nativeToggleEnabled, string keystoreFilen { var variables = new CalamariVariables(); variables.Set(SpecialVariables.Action.Java.JavaKeystore.Variable, CertificateVariableName); - variables.Set(SpecialVariables.Action.Java.JavaKeystore.Password, "sekret"); + variables.Set(SpecialVariables.Action.Java.JavaKeystore.Password, Some.String()); variables.Set(SpecialVariables.Action.Java.JavaKeystore.KeystoreFilename, keystoreFilename); variables.Set(SpecialVariables.Action.Java.JavaKeystore.KeystoreAlias, "myalias"); variables.Set(SpecialVariables.Certificate.PrivateKeyPem(CertificateVariableName), privateKeyPem); diff --git a/source/Calamari.Tests/Java/Fixtures/JavaKeystoreBuilderFixture.cs b/source/Calamari.Tests/Java/Fixtures/JavaKeystoreBuilderFixture.cs index f5d4308940..df32dd91d8 100644 --- a/source/Calamari.Tests/Java/Fixtures/JavaKeystoreBuilderFixture.cs +++ b/source/Calamari.Tests/Java/Fixtures/JavaKeystoreBuilderFixture.cs @@ -1,14 +1,12 @@ using System; using System.IO; using Calamari.Common.Commands; -using Calamari.Common.Plumbing.Logging; using Calamari.Integration.Certificates.Java; using Calamari.Testing.Helpers; using FluentAssertions; using NUnit.Framework; using Org.BouncyCastle.Crypto; using Org.BouncyCastle.Crypto.Generators; -using Org.BouncyCastle.Crypto.Parameters; using Org.BouncyCastle.OpenSsl; using Org.BouncyCastle.Pkcs; using Org.BouncyCastle.Security; @@ -45,8 +43,8 @@ public void SaveKeystoreToFile_WithRsaKeyAndSingleCertificate_ProducesAValidPkcs var keystorePath = Path.Combine(workingDirectory, "test.p12"); var builder = new JavaKeystoreBuilder(log); - - var resultPath = builder.SaveKeystoreToFile("myalias", privateKeyPem, certificatePem, "sekret", keystorePath); + var testPassword = Some.String(); + var resultPath = builder.SaveKeystoreToFile("myalias", privateKeyPem, certificatePem, testPassword, keystorePath); resultPath.Should().Be(Path.GetFullPath(keystorePath)); File.Exists(keystorePath).Should().BeTrue(); @@ -54,7 +52,7 @@ public void SaveKeystoreToFile_WithRsaKeyAndSingleCertificate_ProducesAValidPkcs var reloaded = new Pkcs12StoreBuilder().Build(); using (var fileStream = new FileStream(keystorePath, FileMode.Open)) { - reloaded.Load(fileStream, "sekret".ToCharArray()); + reloaded.Load(fileStream, testPassword.ToCharArray()); } reloaded.ContainsAlias("myalias").Should().BeTrue(); @@ -93,7 +91,7 @@ public void SaveKeystoreToFile_WithRelativeKeystorePath_ThrowsCommandException() var (privateKeyPem, certificatePem, _, _) = CreateSelfSignedRsaCertificate("CN=octopus-relative-path-test"); var builder = new JavaKeystoreBuilder(log); - Action act = () => builder.SaveKeystoreToFile("myalias", privateKeyPem, certificatePem, "sekret", "relative/path.p12"); + Action act = () => builder.SaveKeystoreToFile("myalias", privateKeyPem, certificatePem, Some.String(), "relative/path.p12"); act.Should().Throw().WithMessage("*absolute path*"); } @@ -105,7 +103,7 @@ public void SaveKeystoreToFile_WithNoCertificatesInPem_ThrowsCommandException() var builder = new JavaKeystoreBuilder(log); var keystorePath = Path.Combine(workingDirectory, "no-cert.p12"); - Action act = () => builder.SaveKeystoreToFile("myalias", privateKeyPem, "not a certificate", "sekret", keystorePath); + Action act = () => builder.SaveKeystoreToFile("myalias", privateKeyPem, "not a certificate", Some.String(), keystorePath); act.Should().Throw().WithMessage("*does not contain any certificates*"); } @@ -119,7 +117,7 @@ public void BuildPkcs12Store_WithCertificateChain_IncludesEveryCertificateInOrde var (leafKeyPem, leafCertPem, _, leafCert) = CreateRsaCertificate("CN=leaf", "CN=intermediate", intermediateKeyPair); var builder = new JavaKeystoreBuilder(log); - var store = builder.BuildPkcs12Store("myalias", leafKeyPem, leafCertPem + "\n" + intermediateCertPem, "sekret"); + var store = builder.BuildPkcs12Store("myalias", leafKeyPem, leafCertPem + "\n" + intermediateCertPem, Some.String()); var chain = store.GetCertificateChain("myalias"); chain.Should().HaveCount(2);