Skip to content

Commit e1b9419

Browse files
added the .skip(1) on the chain results from BC to address issue where leaf was showing up twice in AWS. Added unit tests and improved determination of whether a string is a cert ARN.
1 parent 0a8ea95 commit e1b9419

9 files changed

Lines changed: 340 additions & 41 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
3.0.3
2+
* Bug Fix - On Management Add/renewal jobs, the leaf certificate is no longer included in the `CertificateChain` sent to ACM. BouncyCastle's `GetCertificateChain` returns the leaf as the first element, and it was already sent separately as the certificate body, causing the leaf to appear twice within the published certificate's chain. When the certificate has no intermediates, the chain is now omitted entirely rather than sent empty.
3+
14
3.0.2
25
* Bug Fix - On Management jobs, do not send ACM tags if the certificate is being renewed/replaced
36

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
2+
// Copyright 2026 Keyfactor
3+
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
5+
// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS,
6+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions
7+
// and limitations under the License.
8+
9+
using System;
10+
using Org.BouncyCastle.Asn1.X509;
11+
using Org.BouncyCastle.Crypto;
12+
using Org.BouncyCastle.Crypto.Generators;
13+
using Org.BouncyCastle.Crypto.Operators;
14+
using Org.BouncyCastle.Math;
15+
using Org.BouncyCastle.Pkcs;
16+
using Org.BouncyCastle.Security;
17+
using Org.BouncyCastle.X509;
18+
19+
namespace Keyfactor.Extensions.Orchestrator.Aws.Acm.Tests
20+
{
21+
/// <summary>
22+
/// Builds RSA key pairs, X.509 certificates, and PKCS#12 stores entirely with BouncyCastle
23+
/// (no .NET CNG interop), so tests run deterministically on Windows without hitting the
24+
/// private-key export restrictions that affect keys imported through the .NET certificate APIs.
25+
/// Chains are set explicitly on the key entry so <c>Pkcs12Store.GetCertificateChain</c> returns
26+
/// exactly the certificates we provide, in the order we provide them (leaf first).
27+
/// </summary>
28+
internal static class BcCertFactory
29+
{
30+
private const string SignatureAlgorithm = "SHA256WithRSA";
31+
32+
public static AsymmetricCipherKeyPair GenerateRsaKeyPair()
33+
{
34+
var generator = new RsaKeyPairGenerator();
35+
generator.Init(new KeyGenerationParameters(new SecureRandom(), 2048));
36+
return generator.GenerateKeyPair();
37+
}
38+
39+
/// <summary>
40+
/// Creates a certificate for <paramref name="subjectCommonName"/> signed by the holder of
41+
/// <paramref name="issuerPrivateKey"/>. For a self-signed cert, pass the same common name for
42+
/// subject and issuer and the subject's own key pair for public/private.
43+
/// </summary>
44+
public static X509Certificate CreateCertificate(
45+
string subjectCommonName,
46+
string issuerCommonName,
47+
AsymmetricKeyParameter subjectPublicKey,
48+
AsymmetricKeyParameter issuerPrivateKey)
49+
{
50+
var generator = new X509V3CertificateGenerator();
51+
generator.SetSerialNumber(BigInteger.ProbablePrime(120, new SecureRandom()));
52+
generator.SetIssuerDN(new X509Name($"CN={issuerCommonName}"));
53+
generator.SetSubjectDN(new X509Name($"CN={subjectCommonName}"));
54+
generator.SetNotBefore(DateTime.UtcNow.AddDays(-1));
55+
generator.SetNotAfter(DateTime.UtcNow.AddYears(1));
56+
generator.SetPublicKey(subjectPublicKey);
57+
58+
var signatureFactory = new Asn1SignatureFactory(SignatureAlgorithm, issuerPrivateKey, new SecureRandom());
59+
return generator.Generate(signatureFactory);
60+
}
61+
62+
/// <summary>Creates a self-signed certificate (issuer == subject, signed by its own key).</summary>
63+
public static (X509Certificate Certificate, AsymmetricCipherKeyPair KeyPair) CreateSelfSigned(string commonName)
64+
{
65+
var keyPair = GenerateRsaKeyPair();
66+
var certificate = CreateCertificate(commonName, commonName, keyPair.Public, keyPair.Private);
67+
return (certificate, keyPair);
68+
}
69+
70+
/// <summary>
71+
/// Builds a PKCS#12 store containing a single key entry whose certificate chain is exactly
72+
/// <paramref name="chain"/>, in the order supplied (leaf first, then intermediates / root).
73+
/// </summary>
74+
public static Pkcs12Store BuildStore(string alias, AsymmetricKeyParameter privateKey, params X509Certificate[] chain)
75+
{
76+
var store = new Pkcs12StoreBuilder().Build();
77+
78+
var certificateEntries = new X509CertificateEntry[chain.Length];
79+
for (int i = 0; i < chain.Length; i++)
80+
{
81+
certificateEntries[i] = new X509CertificateEntry(chain[i]);
82+
}
83+
84+
store.SetKeyEntry(alias, new AsymmetricKeyEntry(privateKey), certificateEntries);
85+
return store;
86+
}
87+
}
88+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
2+
// Copyright 2026 Keyfactor
3+
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
5+
// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS,
6+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions
7+
// and limitations under the License.
8+
9+
using System.Collections.Generic;
10+
using System.IO;
11+
using System.Text;
12+
using FluentAssertions;
13+
using Keyfactor.Extensions.Orchestrator.Aws.Acm.Jobs;
14+
using Org.BouncyCastle.OpenSsl;
15+
using Org.BouncyCastle.Pkcs;
16+
using Org.BouncyCastle.X509;
17+
using Xunit;
18+
19+
namespace Keyfactor.Extensions.Orchestrator.Aws.Acm.Tests
20+
{
21+
/// <summary>
22+
/// Covers <see cref="Management.GetChain"/>. The leaf/end-entity certificate is sent to ACM
23+
/// separately as the Certificate body of the ImportCertificateRequest, so it must NOT appear in
24+
/// the CertificateChain. Including it caused the leaf to show up twice within a published
25+
/// certificate's chain - the bug these tests guard against.
26+
/// </summary>
27+
public class GetChainTests
28+
{
29+
private const string KeyAlias = "leaf-entry";
30+
31+
[Fact]
32+
public void GetChain_LeafAndRoot_ReturnsOnlyTheRoot_NotTheLeaf()
33+
{
34+
// Root signs the leaf directly; the PKCS#12 chain is [leaf, root].
35+
var (root, rootKeyPair) = BcCertFactory.CreateSelfSigned("Test Root CA");
36+
var leafKeyPair = BcCertFactory.GenerateRsaKeyPair();
37+
var leaf = BcCertFactory.CreateCertificate("Test Leaf", "Test Root CA", leafKeyPair.Public, rootKeyPair.Private);
38+
39+
var store = BcCertFactory.BuildStore(KeyAlias, leafKeyPair.Private, leaf, root);
40+
41+
var subjects = ReadChainSubjects(store, KeyAlias);
42+
43+
subjects.Should().ContainSingle().Which.Should().Be("CN=Test Root CA");
44+
subjects.Should().NotContain("CN=Test Leaf");
45+
}
46+
47+
[Fact]
48+
public void GetChain_LeafIntermediateAndRoot_ReturnsIntermediateAndRoot_InOrder_WithoutLeaf()
49+
{
50+
// Root -> Intermediate -> Leaf; the PKCS#12 chain is [leaf, intermediate, root].
51+
var (root, rootKeyPair) = BcCertFactory.CreateSelfSigned("Test Root CA");
52+
var intermediateKeyPair = BcCertFactory.GenerateRsaKeyPair();
53+
var intermediate = BcCertFactory.CreateCertificate("Test Intermediate CA", "Test Root CA", intermediateKeyPair.Public, rootKeyPair.Private);
54+
var leafKeyPair = BcCertFactory.GenerateRsaKeyPair();
55+
var leaf = BcCertFactory.CreateCertificate("Test Leaf", "Test Intermediate CA", leafKeyPair.Public, intermediateKeyPair.Private);
56+
57+
var store = BcCertFactory.BuildStore(KeyAlias, leafKeyPair.Private, leaf, intermediate, root);
58+
59+
var subjects = ReadChainSubjects(store, KeyAlias);
60+
61+
subjects.Should().Equal("CN=Test Intermediate CA", "CN=Test Root CA");
62+
subjects.Should().NotContain("CN=Test Leaf");
63+
}
64+
65+
[Fact]
66+
public void GetChain_LeafOnly_ReturnsNull()
67+
{
68+
// A self-signed leaf with no issuers above it: nothing to send as a chain.
69+
var leafKeyPair = BcCertFactory.GenerateRsaKeyPair();
70+
var leaf = BcCertFactory.CreateCertificate("Test Leaf", "Test Leaf", leafKeyPair.Public, leafKeyPair.Private);
71+
72+
var store = BcCertFactory.BuildStore(KeyAlias, leafKeyPair.Private, leaf);
73+
74+
MemoryStream chainStream = Management.GetChain(store, KeyAlias);
75+
76+
chainStream.Should().BeNull("a leaf-only PFX has no intermediates, so the chain must be omitted rather than sent empty");
77+
}
78+
79+
// Invokes the production GetChain and parses the PEM it produces back into subject DNs.
80+
private static List<string> ReadChainSubjects(Pkcs12Store store, string alias)
81+
{
82+
using (MemoryStream chainStream = Management.GetChain(store, alias))
83+
{
84+
chainStream.Should().NotBeNull("a chain containing intermediates should produce PEM output");
85+
86+
string pem = Encoding.ASCII.GetString(chainStream.ToArray());
87+
88+
var subjects = new List<string>();
89+
using (var reader = new StringReader(pem))
90+
{
91+
var pemReader = new PemReader(reader);
92+
object parsed;
93+
while ((parsed = pemReader.ReadObject()) != null)
94+
{
95+
if (parsed is X509Certificate certificate)
96+
{
97+
subjects.Add(certificate.SubjectDN.ToString());
98+
}
99+
}
100+
}
101+
102+
return subjects;
103+
}
104+
}
105+
}
106+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
2+
// Copyright 2026 Keyfactor
3+
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
5+
// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS,
6+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions
7+
// and limitations under the License.
8+
9+
using FluentAssertions;
10+
using Keyfactor.Extensions.Orchestrator.Aws.Acm.Jobs;
11+
using Xunit;
12+
13+
namespace Keyfactor.Extensions.Orchestrator.Aws.Acm.Tests
14+
{
15+
/// <summary>
16+
/// Covers <see cref="Management.IsAcmCertificateArn"/>, which decides whether an Add job is a
17+
/// replace/renewal of an existing ACM certificate (alias is an ACM ARN) or a brand-new import.
18+
/// This replaced a brittle "alias length &gt;= 20" heuristic that could misclassify a long
19+
/// friendly alias as an ARN.
20+
/// </summary>
21+
public class IsAcmCertificateArnTests
22+
{
23+
[Theory]
24+
[InlineData("arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012")]
25+
[InlineData("arn:aws:acm:eu-west-2:000000000000:certificate/abcdef")]
26+
[InlineData(" arn:aws:acm:us-east-1:123456789012:certificate/abc ")] // surrounding whitespace is tolerated
27+
[InlineData("ARN:AWS:ACM:us-east-1:123456789012:certificate/abc")] // prefix match is case-insensitive
28+
public void IsAcmCertificateArn_AcmCertificateArns_ReturnTrue(string alias)
29+
{
30+
Management.IsAcmCertificateArn(alias).Should().BeTrue();
31+
}
32+
33+
[Theory]
34+
[InlineData("prod-web-2025")]
35+
[InlineData("my-friendly-cert-alias-2025")] // 27 chars: WOULD have passed the old "length >= 20" heuristic
36+
[InlineData("")]
37+
[InlineData(null)]
38+
[InlineData(" ")]
39+
[InlineData("arn:aws:iam::123456789012:role/MyRole")] // an ARN, but not for ACM
40+
[InlineData("arn:aws:acm:us-east-1:123456789012:certificate-authority/abc")] // ACM PCA-style, not a certificate
41+
public void IsAcmCertificateArn_NonAcmCertificateAliases_ReturnFalse(string alias)
42+
{
43+
Management.IsAcmCertificateArn(alias).Should().BeFalse();
44+
}
45+
}
46+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net8.0</TargetFramework>
5+
<ImplicitUsings>disable</ImplicitUsings>
6+
<Nullable>disable</Nullable>
7+
<IsPackable>false</IsPackable>
8+
<RootNamespace>Keyfactor.Extensions.Orchestrator.Aws.Acm.Tests</RootNamespace>
9+
<AssemblyName>Keyfactor.Extensions.Orchestrator.Aws.Acm.Tests</AssemblyName>
10+
</PropertyGroup>
11+
12+
<ItemGroup>
13+
<PackageReference Include="Keyfactor.Logging" Version="1.3.0" />
14+
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
15+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
16+
<PackageReference Include="xunit" Version="2.9.3" />
17+
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
18+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
19+
<PrivateAssets>all</PrivateAssets>
20+
</PackageReference>
21+
<PackageReference Include="FluentAssertions" Version="8.10.0" />
22+
<PackageReference Include="Portable.BouncyCastle" Version="1.9.0" />
23+
</ItemGroup>
24+
25+
<ItemGroup>
26+
<ProjectReference Include="..\aws-acm-orchestrator\aws-acm-orchestrator.csproj" />
27+
</ItemGroup>
28+
29+
</Project>

aws-acm-orchestrator.sln

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ VisualStudioVersion = 16.0.30717.126
55
MinimumVisualStudioVersion = 10.0.40219.1
66
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "aws-acm-orchestrator", "aws-acm-orchestrator\aws-acm-orchestrator.csproj", "{9BEDC094-06DC-40C6-B5DF-88B4960D4DCF}"
77
EndProject
8+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "aws-acm-orchestrator.Tests", "aws-acm-orchestrator.Tests\aws-acm-orchestrator.Tests.csproj", "{A1B2C3D4-E5F6-47A8-9B0C-1D2E3F4A5B6C}"
9+
EndProject
810
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{515E06CB-9355-43EA-AF70-27F066A5C3AF}"
911
ProjectSection(SolutionItems) = preProject
1012
integration-manifest.json = integration-manifest.json
@@ -45,6 +47,10 @@ Global
4547
{9BEDC094-06DC-40C6-B5DF-88B4960D4DCF}.Debug|Any CPU.Build.0 = Debug|Any CPU
4648
{9BEDC094-06DC-40C6-B5DF-88B4960D4DCF}.Release|Any CPU.ActiveCfg = Release|Any CPU
4749
{9BEDC094-06DC-40C6-B5DF-88B4960D4DCF}.Release|Any CPU.Build.0 = Release|Any CPU
50+
{A1B2C3D4-E5F6-47A8-9B0C-1D2E3F4A5B6C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
51+
{A1B2C3D4-E5F6-47A8-9B0C-1D2E3F4A5B6C}.Debug|Any CPU.Build.0 = Debug|Any CPU
52+
{A1B2C3D4-E5F6-47A8-9B0C-1D2E3F4A5B6C}.Release|Any CPU.ActiveCfg = Release|Any CPU
53+
{A1B2C3D4-E5F6-47A8-9B0C-1D2E3F4A5B6C}.Release|Any CPU.Build.0 = Release|Any CPU
4854
EndGlobalSection
4955
GlobalSection(SolutionProperties) = preSolution
5056
HideSolutionNode = FALSE

aws-acm-orchestrator/Jobs/Inventory.cs

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,10 @@
1-
// Copyright 2025 Keyfactor
2-
//
3-
// Licensed under the Apache License, Version 2.0 (the "License");
4-
// you may not use this file except in compliance with the License.
5-
// You may obtain a copy of the License at
6-
//
7-
// http://www.apache.org/licenses/LICENSE-2.0
8-
//
9-
// Unless required by applicable law or agreed to in writing, software
10-
// distributed under the License is distributed on an "AS IS" BASIS,
11-
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12-
// See the License for the specific language governing permissions and
13-
// limitations under the License.
1+

2+
// Copyright 2026 Keyfactor
3+
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
5+
// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS,
6+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions
7+
// and limitations under the License.
148

159
using Amazon.CertificateManager;
1610
using Amazon.CertificateManager.Model;

0 commit comments

Comments
 (0)