Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -348,3 +348,4 @@ MigrationBackup/

# Ionide (cross platform F# VS Code tools) working folder
.ionide/
/aws-acm-orchestrator.sln.licenseheader
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
3.0.3
* 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.0.2
* Bug Fix - On Management jobs, do not send ACM tags if the certificate is being renewed/replaced

Expand Down
115 changes: 111 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,105 @@ the Keyfactor Command Portal

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


###### Use Default SDK Auth
A switch to enable the store to use Default SDK credentials

![AWS-ACM-v3 Custom Field - UseDefaultSdkAuth](docsource/images/AWS-ACM-v3-custom-field-UseDefaultSdkAuth-dialog.png)
![AWS-ACM-v3 Custom Field - UseDefaultSdkAuth](docsource/images/AWS-ACM-v3-custom-field-UseDefaultSdkAuth-validation-options-dialog.png)



###### Assume new Role using Default SDK Auth
A switch to enable the store to assume a new Role when using Default SDK credentials

![AWS-ACM-v3 Custom Field - DefaultSdkAssumeRole](docsource/images/AWS-ACM-v3-custom-field-DefaultSdkAssumeRole-dialog.png)
![AWS-ACM-v3 Custom Field - DefaultSdkAssumeRole](docsource/images/AWS-ACM-v3-custom-field-DefaultSdkAssumeRole-validation-options-dialog.png)



###### Use OAuth 2.0 Provider
A switch to enable the store to use an OAuth provider workflow to authenticate with AWS

![AWS-ACM-v3 Custom Field - UseOAuth](docsource/images/AWS-ACM-v3-custom-field-UseOAuth-dialog.png)
![AWS-ACM-v3 Custom Field - UseOAuth](docsource/images/AWS-ACM-v3-custom-field-UseOAuth-validation-options-dialog.png)



###### OAuth Scope
This is the OAuth Scope needed for Okta OAuth, defined in Okta

![AWS-ACM-v3 Custom Field - OAuthScope](docsource/images/AWS-ACM-v3-custom-field-OAuthScope-dialog.png)
![AWS-ACM-v3 Custom Field - OAuthScope](docsource/images/AWS-ACM-v3-custom-field-OAuthScope-validation-options-dialog.png)



###### OAuth Grant Type
In OAuth 2.0, the term 'grant type' refers to the way an application gets an access token. In Okta this is `client_credentials`

![AWS-ACM-v3 Custom Field - OAuthGrantType](docsource/images/AWS-ACM-v3-custom-field-OAuthGrantType-dialog.png)
![AWS-ACM-v3 Custom Field - OAuthGrantType](docsource/images/AWS-ACM-v3-custom-field-OAuthGrantType-validation-options-dialog.png)



###### OAuth Url
An optional parameter sts:ExternalId to pass with Assume Role calls

![AWS-ACM-v3 Custom Field - OAuthUrl](docsource/images/AWS-ACM-v3-custom-field-OAuthUrl-dialog.png)
![AWS-ACM-v3 Custom Field - OAuthUrl](docsource/images/AWS-ACM-v3-custom-field-OAuthUrl-validation-options-dialog.png)



###### OAuth Client ID
The Client ID for OAuth.

![AWS-ACM-v3 Custom Field - OAuthClientId](docsource/images/AWS-ACM-v3-custom-field-OAuthClientId-dialog.png)
![AWS-ACM-v3 Custom Field - OAuthClientId](docsource/images/AWS-ACM-v3-custom-field-OAuthClientId-validation-options-dialog.png)



###### OAuth Client Secret
The Client Secret for OAuth.

![AWS-ACM-v3 Custom Field - OAuthClientSecret](docsource/images/AWS-ACM-v3-custom-field-OAuthClientSecret-dialog.png)
![AWS-ACM-v3 Custom Field - OAuthClientSecret](docsource/images/AWS-ACM-v3-custom-field-OAuthClientSecret-validation-options-dialog.png)



###### Use IAM User Auth
A switch to enable the store to use IAM User auth to assume a role when authenticating with AWS

![AWS-ACM-v3 Custom Field - UseIAM](docsource/images/AWS-ACM-v3-custom-field-UseIAM-dialog.png)
![AWS-ACM-v3 Custom Field - UseIAM](docsource/images/AWS-ACM-v3-custom-field-UseIAM-validation-options-dialog.png)



###### IAM User Access Key
The AWS Access Key for an IAM User

![AWS-ACM-v3 Custom Field - IAMUserAccessKey](docsource/images/AWS-ACM-v3-custom-field-IAMUserAccessKey-dialog.png)
![AWS-ACM-v3 Custom Field - IAMUserAccessKey](docsource/images/AWS-ACM-v3-custom-field-IAMUserAccessKey-validation-options-dialog.png)



###### IAM User Access Secret
The AWS Access Secret for an IAM User.

![AWS-ACM-v3 Custom Field - IAMUserAccessSecret](docsource/images/AWS-ACM-v3-custom-field-IAMUserAccessSecret-dialog.png)
![AWS-ACM-v3 Custom Field - IAMUserAccessSecret](docsource/images/AWS-ACM-v3-custom-field-IAMUserAccessSecret-validation-options-dialog.png)



###### sts:ExternalId
An optional parameter sts:ExternalId to pass with Assume Role calls

![AWS-ACM-v3 Custom Field - ExternalId](docsource/images/AWS-ACM-v3-custom-field-ExternalId-dialog.png)
![AWS-ACM-v3 Custom Field - ExternalId](docsource/images/AWS-ACM-v3-custom-field-ExternalId-validation-options-dialog.png)





##### Entry Parameters Tab

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

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


##### ACM Tags
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`

![AWS-ACM-v3 Entry Parameter - ACM Tags](docsource/images/AWS-ACM-v3-entry-parameters-store-type-dialog-ACM Tags.png)
![AWS-ACM-v3 Entry Parameter - ACM Tags](docsource/images/AWS-ACM-v3-entry-parameters-store-type-dialog-ACM Tags-validation-options.png)



</details>

## Installation

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

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.
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.

| Universal Orchestrator Version | Latest .NET version installed on the Universal Orchestrator server | `rollForward` condition in `Orchestrator.runtimeconfig.json` | `aws-orchestrator` .NET version to download |
| --------- | ----------- | ----------- | ----------- |
| Older than `11.0.0` | | | `net6.0` |
| Between `11.0.0` and `11.5.1` (inclusive) | `net6.0` | | `net6.0` |
| 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` |
| `11.6` _and_ newer | `net8.0` | | `net8.0` |
| 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` |
| `11.6` _and_ newer | `net8.0` | | `net8.0` |

Unzip the archive containing extension assemblies to a known location.

Expand Down
88 changes: 88 additions & 0 deletions aws-acm-orchestrator.Tests/BcCertFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@

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

using System;
using Org.BouncyCastle.Asn1.X509;
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Operators;
using Org.BouncyCastle.Math;
using Org.BouncyCastle.Pkcs;
using Org.BouncyCastle.Security;
using Org.BouncyCastle.X509;

namespace Keyfactor.Extensions.Orchestrator.Aws.Acm.Tests
{
/// <summary>
/// Builds RSA key pairs, X.509 certificates, and PKCS#12 stores entirely with BouncyCastle
/// (no .NET CNG interop), so tests run deterministically on Windows without hitting the
/// private-key export restrictions that affect keys imported through the .NET certificate APIs.
/// Chains are set explicitly on the key entry so <c>Pkcs12Store.GetCertificateChain</c> returns
/// exactly the certificates we provide, in the order we provide them (leaf first).
/// </summary>
internal static class BcCertFactory
{
private const string SignatureAlgorithm = "SHA256WithRSA";

public static AsymmetricCipherKeyPair GenerateRsaKeyPair()
{
var generator = new RsaKeyPairGenerator();
generator.Init(new KeyGenerationParameters(new SecureRandom(), 2048));
return generator.GenerateKeyPair();
}

/// <summary>
/// Creates a certificate for <paramref name="subjectCommonName"/> signed by the holder of
/// <paramref name="issuerPrivateKey"/>. For a self-signed cert, pass the same common name for
/// subject and issuer and the subject's own key pair for public/private.
/// </summary>
public static X509Certificate CreateCertificate(
string subjectCommonName,
string issuerCommonName,
AsymmetricKeyParameter subjectPublicKey,
AsymmetricKeyParameter issuerPrivateKey)
{
var generator = new X509V3CertificateGenerator();
generator.SetSerialNumber(BigInteger.ProbablePrime(120, new SecureRandom()));
generator.SetIssuerDN(new X509Name($"CN={issuerCommonName}"));
generator.SetSubjectDN(new X509Name($"CN={subjectCommonName}"));
generator.SetNotBefore(DateTime.UtcNow.AddDays(-1));
generator.SetNotAfter(DateTime.UtcNow.AddYears(1));
generator.SetPublicKey(subjectPublicKey);

var signatureFactory = new Asn1SignatureFactory(SignatureAlgorithm, issuerPrivateKey, new SecureRandom());
return generator.Generate(signatureFactory);
}

/// <summary>Creates a self-signed certificate (issuer == subject, signed by its own key).</summary>
public static (X509Certificate Certificate, AsymmetricCipherKeyPair KeyPair) CreateSelfSigned(string commonName)
{
var keyPair = GenerateRsaKeyPair();
var certificate = CreateCertificate(commonName, commonName, keyPair.Public, keyPair.Private);
return (certificate, keyPair);
}

/// <summary>
/// Builds a PKCS#12 store containing a single key entry whose certificate chain is exactly
/// <paramref name="chain"/>, in the order supplied (leaf first, then intermediates / root).
/// </summary>
public static Pkcs12Store BuildStore(string alias, AsymmetricKeyParameter privateKey, params X509Certificate[] chain)
{
var store = new Pkcs12StoreBuilder().Build();

var certificateEntries = new X509CertificateEntry[chain.Length];
for (int i = 0; i < chain.Length; i++)
{
certificateEntries[i] = new X509CertificateEntry(chain[i]);
}

store.SetKeyEntry(alias, new AsymmetricKeyEntry(privateKey), certificateEntries);
return store;
}
}
}
106 changes: 106 additions & 0 deletions aws-acm-orchestrator.Tests/GetChainTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@

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

using System.Collections.Generic;
using System.IO;
using System.Text;
using FluentAssertions;
using Keyfactor.Extensions.Orchestrator.Aws.Acm.Jobs;
using Org.BouncyCastle.OpenSsl;
using Org.BouncyCastle.Pkcs;
using Org.BouncyCastle.X509;
using Xunit;

namespace Keyfactor.Extensions.Orchestrator.Aws.Acm.Tests
{
/// <summary>
/// Covers <see cref="Management.GetChain"/>. The leaf/end-entity certificate is sent to ACM
/// separately as the Certificate body of the ImportCertificateRequest, so it must NOT appear in
/// the CertificateChain. Including it caused the leaf to show up twice within a published
/// certificate's chain - the bug these tests guard against.
/// </summary>
public class GetChainTests
{
private const string KeyAlias = "leaf-entry";

[Fact]
public void GetChain_LeafAndRoot_ReturnsOnlyTheRoot_NotTheLeaf()
{
// Root signs the leaf directly; the PKCS#12 chain is [leaf, root].
var (root, rootKeyPair) = BcCertFactory.CreateSelfSigned("Test Root CA");
var leafKeyPair = BcCertFactory.GenerateRsaKeyPair();
var leaf = BcCertFactory.CreateCertificate("Test Leaf", "Test Root CA", leafKeyPair.Public, rootKeyPair.Private);

var store = BcCertFactory.BuildStore(KeyAlias, leafKeyPair.Private, leaf, root);

var subjects = ReadChainSubjects(store, KeyAlias);

subjects.Should().ContainSingle().Which.Should().Be("CN=Test Root CA");
subjects.Should().NotContain("CN=Test Leaf");
}

[Fact]
public void GetChain_LeafIntermediateAndRoot_ReturnsIntermediateAndRoot_InOrder_WithoutLeaf()
{
// Root -> Intermediate -> Leaf; the PKCS#12 chain is [leaf, intermediate, root].
var (root, rootKeyPair) = BcCertFactory.CreateSelfSigned("Test Root CA");
var intermediateKeyPair = BcCertFactory.GenerateRsaKeyPair();
var intermediate = BcCertFactory.CreateCertificate("Test Intermediate CA", "Test Root CA", intermediateKeyPair.Public, rootKeyPair.Private);
var leafKeyPair = BcCertFactory.GenerateRsaKeyPair();
var leaf = BcCertFactory.CreateCertificate("Test Leaf", "Test Intermediate CA", leafKeyPair.Public, intermediateKeyPair.Private);

var store = BcCertFactory.BuildStore(KeyAlias, leafKeyPair.Private, leaf, intermediate, root);

var subjects = ReadChainSubjects(store, KeyAlias);

subjects.Should().Equal("CN=Test Intermediate CA", "CN=Test Root CA");
subjects.Should().NotContain("CN=Test Leaf");
}

[Fact]
public void GetChain_LeafOnly_ReturnsNull()
{
// A self-signed leaf with no issuers above it: nothing to send as a chain.
var leafKeyPair = BcCertFactory.GenerateRsaKeyPair();
var leaf = BcCertFactory.CreateCertificate("Test Leaf", "Test Leaf", leafKeyPair.Public, leafKeyPair.Private);

var store = BcCertFactory.BuildStore(KeyAlias, leafKeyPair.Private, leaf);

MemoryStream chainStream = Management.GetChain(store, KeyAlias);

chainStream.Should().BeNull("a leaf-only PFX has no intermediates, so the chain must be omitted rather than sent empty");
}

// Invokes the production GetChain and parses the PEM it produces back into subject DNs.
private static List<string> ReadChainSubjects(Pkcs12Store store, string alias)
{
using (MemoryStream chainStream = Management.GetChain(store, alias))
{
chainStream.Should().NotBeNull("a chain containing intermediates should produce PEM output");

string pem = Encoding.ASCII.GetString(chainStream.ToArray());

var subjects = new List<string>();
using (var reader = new StringReader(pem))
{
var pemReader = new PemReader(reader);
object parsed;
while ((parsed = pemReader.ReadObject()) != null)
{
if (parsed is X509Certificate certificate)
{
subjects.Add(certificate.SubjectDN.ToString());
}
}
}

return subjects;
}
}
}
}
46 changes: 46 additions & 0 deletions aws-acm-orchestrator.Tests/IsAcmCertificateArnTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@

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

using FluentAssertions;
using Keyfactor.Extensions.Orchestrator.Aws.Acm.Jobs;
using Xunit;

namespace Keyfactor.Extensions.Orchestrator.Aws.Acm.Tests
{
/// <summary>
/// Covers <see cref="Management.IsAcmCertificateArn"/>, which decides whether an Add job is a
/// replace/renewal of an existing ACM certificate (alias is an ACM ARN) or a brand-new import.
/// This replaced a brittle "alias length &gt;= 20" heuristic that could misclassify a long
/// friendly alias as an ARN.
/// </summary>
public class IsAcmCertificateArnTests
{
[Theory]
[InlineData("arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012")]
[InlineData("arn:aws:acm:eu-west-2:000000000000:certificate/abcdef")]
[InlineData(" arn:aws:acm:us-east-1:123456789012:certificate/abc ")] // surrounding whitespace is tolerated
[InlineData("ARN:AWS:ACM:us-east-1:123456789012:certificate/abc")] // prefix match is case-insensitive
public void IsAcmCertificateArn_AcmCertificateArns_ReturnTrue(string alias)
{
Management.IsAcmCertificateArn(alias).Should().BeTrue();
}

[Theory]
[InlineData("prod-web-2025")]
[InlineData("my-friendly-cert-alias-2025")] // 27 chars: WOULD have passed the old "length >= 20" heuristic
[InlineData("")]
[InlineData(null)]
[InlineData(" ")]
[InlineData("arn:aws:iam::123456789012:role/MyRole")] // an ARN, but not for ACM
[InlineData("arn:aws:acm:us-east-1:123456789012:certificate-authority/abc")] // ACM PCA-style, not a certificate
public void IsAcmCertificateArn_NonAcmCertificateAliases_ReturnFalse(string alias)
{
Management.IsAcmCertificateArn(alias).Should().BeFalse();
}
}
}
Loading
Loading