Skip to content

Commit ef748b4

Browse files
joevanwanzeeleKFKeyfactor
andauthored
Bug fix: duplicate leaf cert (#39)
* ignoring license header file * 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. * Update generated docs --------- Co-authored-by: Keyfactor <keyfactor@keyfactor.github.io>
1 parent cd11619 commit ef748b4

15 files changed

Lines changed: 953 additions & 45 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,3 +348,4 @@ MigrationBackup/
348348

349349
# Ionide (cross platform F# VS Code tools) working folder
350350
.ionide/
351+
/aws-acm-orchestrator.sln.licenseheader

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

README.md

Lines changed: 111 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,105 @@ the Keyfactor Command Portal
286286

287287
![AWS-ACM-v3 Custom Fields Tab](docsource/images/AWS-ACM-v3-custom-fields-store-type-dialog.png)
288288

289+
290+
###### Use Default SDK Auth
291+
A switch to enable the store to use Default SDK credentials
292+
293+
![AWS-ACM-v3 Custom Field - UseDefaultSdkAuth](docsource/images/AWS-ACM-v3-custom-field-UseDefaultSdkAuth-dialog.png)
294+
![AWS-ACM-v3 Custom Field - UseDefaultSdkAuth](docsource/images/AWS-ACM-v3-custom-field-UseDefaultSdkAuth-validation-options-dialog.png)
295+
296+
297+
298+
###### Assume new Role using Default SDK Auth
299+
A switch to enable the store to assume a new Role when using Default SDK credentials
300+
301+
![AWS-ACM-v3 Custom Field - DefaultSdkAssumeRole](docsource/images/AWS-ACM-v3-custom-field-DefaultSdkAssumeRole-dialog.png)
302+
![AWS-ACM-v3 Custom Field - DefaultSdkAssumeRole](docsource/images/AWS-ACM-v3-custom-field-DefaultSdkAssumeRole-validation-options-dialog.png)
303+
304+
305+
306+
###### Use OAuth 2.0 Provider
307+
A switch to enable the store to use an OAuth provider workflow to authenticate with AWS
308+
309+
![AWS-ACM-v3 Custom Field - UseOAuth](docsource/images/AWS-ACM-v3-custom-field-UseOAuth-dialog.png)
310+
![AWS-ACM-v3 Custom Field - UseOAuth](docsource/images/AWS-ACM-v3-custom-field-UseOAuth-validation-options-dialog.png)
311+
312+
313+
314+
###### OAuth Scope
315+
This is the OAuth Scope needed for Okta OAuth, defined in Okta
316+
317+
![AWS-ACM-v3 Custom Field - OAuthScope](docsource/images/AWS-ACM-v3-custom-field-OAuthScope-dialog.png)
318+
![AWS-ACM-v3 Custom Field - OAuthScope](docsource/images/AWS-ACM-v3-custom-field-OAuthScope-validation-options-dialog.png)
319+
320+
321+
322+
###### OAuth Grant Type
323+
In OAuth 2.0, the term 'grant type' refers to the way an application gets an access token. In Okta this is `client_credentials`
324+
325+
![AWS-ACM-v3 Custom Field - OAuthGrantType](docsource/images/AWS-ACM-v3-custom-field-OAuthGrantType-dialog.png)
326+
![AWS-ACM-v3 Custom Field - OAuthGrantType](docsource/images/AWS-ACM-v3-custom-field-OAuthGrantType-validation-options-dialog.png)
327+
328+
329+
330+
###### OAuth Url
331+
An optional parameter sts:ExternalId to pass with Assume Role calls
332+
333+
![AWS-ACM-v3 Custom Field - OAuthUrl](docsource/images/AWS-ACM-v3-custom-field-OAuthUrl-dialog.png)
334+
![AWS-ACM-v3 Custom Field - OAuthUrl](docsource/images/AWS-ACM-v3-custom-field-OAuthUrl-validation-options-dialog.png)
335+
336+
337+
338+
###### OAuth Client ID
339+
The Client ID for OAuth.
340+
341+
![AWS-ACM-v3 Custom Field - OAuthClientId](docsource/images/AWS-ACM-v3-custom-field-OAuthClientId-dialog.png)
342+
![AWS-ACM-v3 Custom Field - OAuthClientId](docsource/images/AWS-ACM-v3-custom-field-OAuthClientId-validation-options-dialog.png)
343+
344+
345+
346+
###### OAuth Client Secret
347+
The Client Secret for OAuth.
348+
349+
![AWS-ACM-v3 Custom Field - OAuthClientSecret](docsource/images/AWS-ACM-v3-custom-field-OAuthClientSecret-dialog.png)
350+
![AWS-ACM-v3 Custom Field - OAuthClientSecret](docsource/images/AWS-ACM-v3-custom-field-OAuthClientSecret-validation-options-dialog.png)
351+
352+
353+
354+
###### Use IAM User Auth
355+
A switch to enable the store to use IAM User auth to assume a role when authenticating with AWS
356+
357+
![AWS-ACM-v3 Custom Field - UseIAM](docsource/images/AWS-ACM-v3-custom-field-UseIAM-dialog.png)
358+
![AWS-ACM-v3 Custom Field - UseIAM](docsource/images/AWS-ACM-v3-custom-field-UseIAM-validation-options-dialog.png)
359+
360+
361+
362+
###### IAM User Access Key
363+
The AWS Access Key for an IAM User
364+
365+
![AWS-ACM-v3 Custom Field - IAMUserAccessKey](docsource/images/AWS-ACM-v3-custom-field-IAMUserAccessKey-dialog.png)
366+
![AWS-ACM-v3 Custom Field - IAMUserAccessKey](docsource/images/AWS-ACM-v3-custom-field-IAMUserAccessKey-validation-options-dialog.png)
367+
368+
369+
370+
###### IAM User Access Secret
371+
The AWS Access Secret for an IAM User.
372+
373+
![AWS-ACM-v3 Custom Field - IAMUserAccessSecret](docsource/images/AWS-ACM-v3-custom-field-IAMUserAccessSecret-dialog.png)
374+
![AWS-ACM-v3 Custom Field - IAMUserAccessSecret](docsource/images/AWS-ACM-v3-custom-field-IAMUserAccessSecret-validation-options-dialog.png)
375+
376+
377+
378+
###### sts:ExternalId
379+
An optional parameter sts:ExternalId to pass with Assume Role calls
380+
381+
![AWS-ACM-v3 Custom Field - ExternalId](docsource/images/AWS-ACM-v3-custom-field-ExternalId-dialog.png)
382+
![AWS-ACM-v3 Custom Field - ExternalId](docsource/images/AWS-ACM-v3-custom-field-ExternalId-validation-options-dialog.png)
383+
384+
385+
386+
387+
289388
##### Entry Parameters Tab
290389

291390
| Name | Display Name | Description | Type | Default Value | Entry has a private key | Adding an entry | Removing an entry | Reenrolling an entry |
@@ -296,21 +395,29 @@ the Keyfactor Command Portal
296395

297396
![AWS-ACM-v3 Entry Parameters Tab](docsource/images/AWS-ACM-v3-entry-parameters-store-type-dialog.png)
298397

398+
399+
##### ACM Tags
400+
The optional ACM tags that should be assigned to the certificate. Multiple name/value pairs may be entered in the format of `Name1=Value1,Name2=Value2,...,NameN=ValueN`
401+
402+
![AWS-ACM-v3 Entry Parameter - ACM Tags](docsource/images/AWS-ACM-v3-entry-parameters-store-type-dialog-ACM Tags.png)
403+
![AWS-ACM-v3 Entry Parameter - ACM Tags](docsource/images/AWS-ACM-v3-entry-parameters-store-type-dialog-ACM Tags-validation-options.png)
404+
405+
406+
299407
</details>
300408

301409
## Installation
302410

303411
1. **Download the latest AWS Certificate Manager (ACM) Universal Orchestrator extension from GitHub.**
304412

305-
Navigate to the [AWS Certificate Manager (ACM) Universal Orchestrator extension GitHub version page](https://github.com/Keyfactor/aws-orchestrator/releases/latest). Refer to the compatibility matrix below to determine whether the `net6.0` or `net8.0` asset should be downloaded. Then, click the corresponding asset to download the zip archive.
413+
Navigate to the [AWS Certificate Manager (ACM) Universal Orchestrator extension GitHub version page](https://github.com/Keyfactor/aws-orchestrator/releases/latest). Refer to the compatibility matrix below to determine the asset should be downloaded. Then, click the corresponding asset to download the zip archive.
306414

307415
| Universal Orchestrator Version | Latest .NET version installed on the Universal Orchestrator server | `rollForward` condition in `Orchestrator.runtimeconfig.json` | `aws-orchestrator` .NET version to download |
308416
| --------- | ----------- | ----------- | ----------- |
309417
| Older than `11.0.0` | | | `net6.0` |
310418
| Between `11.0.0` and `11.5.1` (inclusive) | `net6.0` | | `net6.0` |
311-
| Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `Disable` | `net6.0` |
312-
| Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `LatestMajor` | `net8.0` |
313-
| `11.6` _and_ newer | `net8.0` | | `net8.0` |
419+
| Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `Disable` | `net6.0` || Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `LatestMajor` | `net8.0` |
420+
| `11.6` _and_ newer | `net8.0` | | `net8.0` |
314421

315422
Unzip the archive containing extension assemblies to a known location.
316423

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+
}

0 commit comments

Comments
 (0)