Skip to content
Merged
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
187 changes: 187 additions & 0 deletions AwsSecretsManager.Tests/ClientPasswordSecretTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
// 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

using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Amazon.SecretsManager;
using Amazon.SecretsManager.Model;
using FluentAssertions;
using Moq;
using Xunit;

namespace Keyfactor.Extensions.Orchestrators.AwsSecretsManager.Tests
{
/// <summary>
/// Verifies that for JKS / PFX stores the password secret ("&lt;alias&gt;-pw") is written with the
/// same KMS key and replica regions as the cert store secret, and that its value is updated
/// when an existing cert store secret is overwritten.
/// </summary>
public class ClientPasswordSecretTests
{
private const string Alias = "my-cert";
private const string PwdSecretName = Alias + "-pw";
private const string KmsKeyId = "arn:aws:kms:us-east-1:123:key/abcd-1234";

// ── Add ─────────────────────────────────────────────────────────────

[Theory]
[InlineData("AWSSMPFX")]
[InlineData("AWSSMJKS")]
public async Task Add_WithKmsKeyAndReplicas_AppliesBothToPasswordSecret(string storeType)
{
var awsMock = MockAws(secretExists: false);
var jp = BuildJobParameters(storeType, KmsKeyId, "us-west-2");

await BuildClient(awsMock.Object).AddOrUpdateSecret(jp);

awsMock.Verify(c => c.CreateSecretAsync(
It.Is<CreateSecretRequest>(r =>
r.Name == PwdSecretName &&
r.SecretString == TestCertFactory.Password &&
r.KmsKeyId == KmsKeyId &&
r.AddReplicaRegions.Count == 1 &&
r.AddReplicaRegions[0].Region == "us-west-2"),
It.IsAny<CancellationToken>()), Times.Once);

awsMock.Verify(c => c.CreateSecretAsync(
It.Is<CreateSecretRequest>(r => r.Name == Alias && r.KmsKeyId == KmsKeyId),
It.IsAny<CancellationToken>()), Times.Once);
}

[Fact]
public async Task Add_WithoutKmsKey_LeavesPasswordSecretOnDefaultKey()
{
var awsMock = MockAws(secretExists: false);
var jp = BuildJobParameters("AWSSMPFX", kmsKeyId: null);

await BuildClient(awsMock.Object).AddOrUpdateSecret(jp);

awsMock.Verify(c => c.CreateSecretAsync(
It.Is<CreateSecretRequest>(r => r.Name == PwdSecretName && string.IsNullOrEmpty(r.KmsKeyId)),
It.IsAny<CancellationToken>()), Times.Once);
}

[Fact]
public async Task Add_WithIdentificationTag_DoesNotTagPasswordSecretForInventory()
{
var awsMock = MockAws(secretExists: false);
var jp = BuildJobParameters("AWSSMPFX", KmsKeyId);
jp.StoreProperties.TagName = "managedBy";
jp.StoreProperties.TagValue = "Keyfactor";

await BuildClient(awsMock.Object).AddOrUpdateSecret(jp);

awsMock.Verify(c => c.CreateSecretAsync(
It.Is<CreateSecretRequest>(r => r.Name == PwdSecretName && r.Tags.All(t => t.Key != "managedBy")),
It.IsAny<CancellationToken>()), Times.Once);
}

// ── Update ──────────────────────────────────────────────────────────

[Theory]
[InlineData("AWSSMPFX")]
[InlineData("AWSSMJKS")]
public async Task Update_WritesNewPasswordWithKmsKey(string storeType)
{
var awsMock = MockAws(secretExists: true);
var jp = BuildJobParameters(storeType, KmsKeyId);

await BuildClient(awsMock.Object).AddOrUpdateSecret(jp);

awsMock.Verify(c => c.UpdateSecretAsync(
It.Is<UpdateSecretRequest>(r =>
r.SecretId == PwdSecretName &&
r.SecretString == TestCertFactory.Password &&
r.KmsKeyId == KmsKeyId),
It.IsAny<CancellationToken>()), Times.Once);

awsMock.Verify(c => c.UpdateSecretAsync(
It.Is<UpdateSecretRequest>(r => r.SecretId == Alias && r.KmsKeyId == KmsKeyId),
It.IsAny<CancellationToken>()), Times.Once);
}

[Fact]
public async Task Update_WithReplicas_ReplicatesPasswordSecret()
{
var awsMock = MockAws(secretExists: true);
var jp = BuildJobParameters("AWSSMPFX", KmsKeyId, "us-west-2");

await BuildClient(awsMock.Object).AddOrUpdateSecret(jp);

awsMock.Verify(c => c.ReplicateSecretToRegionsAsync(
It.Is<ReplicateSecretToRegionsRequest>(r =>
r.SecretId == PwdSecretName &&
r.AddReplicaRegions.Single().Region == "us-west-2"),
It.IsAny<CancellationToken>()), Times.Once);

awsMock.Verify(c => c.ReplicateSecretToRegionsAsync(
It.Is<ReplicateSecretToRegionsRequest>(r => r.SecretId == Alias),
It.IsAny<CancellationToken>()), Times.Once);
}

[Fact]
public async Task Update_Pem_DoesNotTouchPasswordSecret()
{
var awsMock = MockAws(secretExists: true);
var jp = BuildJobParameters("AWSSMPEM", KmsKeyId);

await BuildClient(awsMock.Object).AddOrUpdateSecret(jp);

awsMock.Verify(c => c.UpdateSecretAsync(
It.Is<UpdateSecretRequest>(r => r.SecretId == PwdSecretName),
It.IsAny<CancellationToken>()), Times.Never);
}

// ── helpers ────────────────────────────────────────────────────────

private static AwsSecretsManagerJobParameters BuildJobParameters(string storeType, string? kmsKeyId, params string[] replicaRegions)
{
var jp = new AwsSecretsManagerJobParameters { StoreType = storeType };
jp.CertProperties.Alias = Alias;
jp.CertProperties.Overwrite = true;
jp.CertProperties.KmsKeyId = kmsKeyId;
jp.CertProperties.Contents = TestCertFactory.CreateSelfSignedRsaPfxBase64("CN=" + Alias);
jp.CertProperties.PrivateKeyPassword = TestCertFactory.Password;
jp.CertProperties.ReplicaRegions = replicaRegions.Select(r => new ReplicaRegionType { Region = r }).ToList();
return jp;
}

private static Mock<IAmazonSecretsManager> MockAws(bool secretExists)
{
var awsMock = new Mock<IAmazonSecretsManager>();
awsMock.Setup(c => c.ListSecretsAsync(It.IsAny<ListSecretsRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ListSecretsResponse
{
SecretList = secretExists
? new List<SecretListEntry> { new SecretListEntry { Name = Alias } }
: new List<SecretListEntry>()
});
awsMock.Setup(c => c.CreateSecretAsync(It.IsAny<CreateSecretRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new CreateSecretResponse());
awsMock.Setup(c => c.UpdateSecretAsync(It.IsAny<UpdateSecretRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new UpdateSecretResponse());
awsMock.Setup(c => c.DescribeSecretAsync(It.IsAny<DescribeSecretRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new DescribeSecretResponse { Tags = new List<Tag>(), ReplicationStatus = new List<ReplicationStatusType>() });
awsMock.Setup(c => c.TagResourceAsync(It.IsAny<TagResourceRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new TagResourceResponse());
awsMock.Setup(c => c.ReplicateSecretToRegionsAsync(It.IsAny<ReplicateSecretToRegionsRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ReplicateSecretToRegionsResponse());
return awsMock;
}

private static AwsSecretsManagerClient BuildClient(IAmazonSecretsManager aws)
{
var client = new AwsSecretsManagerClient();
var prop = typeof(AwsSecretsManagerClient).GetProperty(
"_secretsManagerClient", BindingFlags.NonPublic | BindingFlags.Instance);
prop.Should().NotBeNull("the AWS client must be injectable via reflection");
prop!.SetValue(client, aws);
return client;
}
}
}
35 changes: 32 additions & 3 deletions AwsSecretsManager/AwsSecretsManagerClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -336,14 +336,24 @@ private async Task<string> AddSecret(AwsSecretsManagerJobParameters jobParameter
}

// include any explicit encryption key ID
if (!string.IsNullOrEmpty(jobParameters.CertProperties.KmsKeyId)) req.KmsKeyId = jobParameters.CertProperties.KmsKeyId;
// the password secret (JKS / PFX) is encrypted with the same key as the cert store secret
if (!string.IsNullOrEmpty(jobParameters.CertProperties.KmsKeyId))
{
req.KmsKeyId = jobParameters.CertProperties.KmsKeyId;
if (pwdReq != null) pwdReq.KmsKeyId = jobParameters.CertProperties.KmsKeyId;
}

// include any additional replica regions
// the password secret is replicated to the same regions so the cert store can be opened there
if (jobParameters.CertProperties.ReplicaRegions != null && jobParameters.CertProperties.ReplicaRegions.Any())
{
req.AddReplicaRegions = jobParameters.CertProperties.ReplicaRegions;
if (pwdReq != null) pwdReq.AddReplicaRegions = jobParameters.CertProperties.ReplicaRegions;
}

// note: the identification and entry tags are intentionally not applied to the password secret;
// the identification tag would cause inventory to treat the password secret as a cert store.

try
{
_logger.LogTrace($"writing the secret{(pwdReq != null ? "s" : "")} to AWS..");
Expand Down Expand Up @@ -532,13 +542,26 @@ private async Task<string> UpdateSecret(AwsSecretsManagerJobParameters jobParame
}

// include any explicit encryption key ID
if (!string.IsNullOrEmpty(jobParameters.CertProperties.KmsKeyId)) updateCertReq.KmsKeyId = jobParameters.CertProperties.KmsKeyId;
// the password secret (JKS / PFX) is encrypted with the same key as the cert store secret
if (!string.IsNullOrEmpty(jobParameters.CertProperties.KmsKeyId))
{
updateCertReq.KmsKeyId = jobParameters.CertProperties.KmsKeyId;
if (updatePwReq != null) updatePwReq.KmsKeyId = jobParameters.CertProperties.KmsKeyId;
}

UpdateSecretResponse updateCertResp;

// send request to update the secret value
// send request to update the secret value(s)
try
{
if (updatePwReq != null)
{
// the new cert store is protected with the password from this job, so the password secret must be updated as well
_logger.LogTrace($"we will first update the secret containing the password; {updatePwReq.SecretId}");
var updatePwResp = await _secretsManagerClient.UpdateSecretAsync(updatePwReq);
_logger.LogTrace($"successfully updated secret containing the password\nARN: {updatePwResp.ARN}\nversion ID: {updatePwResp.VersionId}");
}

_logger.LogTrace($"sending request to AWS..");
updateCertResp = await _secretsManagerClient.UpdateSecretAsync(updateCertReq);
_logger.LogTrace($"successfully created secret containing the certificate\nARN: {updateCertResp.ARN}\nversion ID: {updateCertResp.VersionId}");
Expand Down Expand Up @@ -593,6 +616,12 @@ private async Task<string> UpdateSecret(AwsSecretsManagerJobParameters jobParame
{
_logger.LogTrace("replica regions were provided, replacing any existing replica regions..");
await ReplaceSecretReplicaRegionsAsync(jobParameters.SecretName, jobParameters.CertProperties.ReplicaRegions);

if (updatePwReq != null)
{
_logger.LogTrace("replacing any existing replica regions for the password secret..");
await ReplaceSecretReplicaRegionsAsync(updatePwReq.SecretId, jobParameters.CertProperties.ReplicaRegions);
}
}
}
catch (Exception ex)
Expand Down
5 changes: 3 additions & 2 deletions AwsSecretsManagerOrchestrator.sln
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.11.35327.3
# Visual Studio Version 18
VisualStudioVersion = 18.8.12009.203 stable
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AwsSecretsManager", "AwsSecretsManager\AwsSecretsManager.csproj", "{ECFD4531-6959-431C-8D5A-8CD62301A82A}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{200366EA-145A-49B7-AE7C-7A0881142553}"
ProjectSection(SolutionItems) = preProject
CHANGELOG.md = CHANGELOG.md
integration-manifest.json = integration-manifest.json
manifest.json = manifest.json
EndProjectSection
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 1.2.1
* AWSSMPFX / AWSSMJKS - Fixed the password secret (`<alias>-pw`) ignoring the `KmsKeyId` entry parameter; it was always encrypted with the default `aws/secretsmanager` key. It is now encrypted with the same KMS key as the certificate secret, and is also replicated to the regions provided in `ReplicaRegions`.
* AWSSMPFX / AWSSMJKS - Fixed the password secret value not being updated when an existing certificate was renewed or overwritten, which left the stored password out of sync with the new PFX / JKS store.

## 1.2.0
* AWSSMPEM - New optional store type parameter `IncludeChain` (default false) to include the issuer chain in the single concatenated PEM secret (leaf, then chain, then private key). Ignored when `SeparatePrivateKey` is enabled, as the JSON format already includes the chain. Stores without the parameter behave as before.
* Added .NET 10 as a target framework.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,8 @@ In summary; supplying a name prefix or tag name and value as part of a certifica
- A tag will be added to the first secret called "PasswordSecret" and will contain the name of the secret containing the password.
- If a "prefix" is defined in the store path, it will be prepended to the names of both secrets.
- If a "TagName" and optional "TagValue" are provided in the store path, those tags will be added to the certificate secret.
- If a "KmsKeyId" or "ReplicaRegions" entry parameter is provided, it is applied to both the certificate secret and the \<alias\>-pw secret.
- When an existing certificate is replaced, the \<alias\>-pw secret is updated with the new PFX password.

##### Example
Enrolling a certificate with the alias "mycert" into a AWSSMPFX certificate store that has a store path value of "us-east-2 [prefix="devteam"]" will result in the following..
Expand Down Expand Up @@ -816,6 +818,8 @@ In summary; supplying a name prefix or tag name and value as part of a certifica
- A tag will be added to the first secret called "PasswordSecret" and will contain the name of the secret containing the password.
- If a "prefix" is defined in the store path, it will be prepended to the names of both secrets.
- If a "TagName" and optional "TagValue" are provided in the store path, those tags will be added to the certificate secret.
- If a "KmsKeyId" or "ReplicaRegions" entry parameter is provided, it is applied to both the certificate secret and the \<alias\>-pw secret.
- When an existing certificate is replaced, the \<alias\>-pw secret is updated with the new JKS password.

##### Example
Enrolling a certificate with the alias "mycert" into a AWSSMJKS certificate store that has a store path value of "us-east-2 [prefix="devteam"]" will result in the following..
Expand Down
2 changes: 2 additions & 0 deletions docsource/awssmjks.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ In summary; supplying a name prefix or tag name and value as part of a certifica
- A tag will be added to the first secret called "PasswordSecret" and will contain the name of the secret containing the password.
- If a "prefix" is defined in the store path, it will be prepended to the names of both secrets.
- If a "TagName" and optional "TagValue" are provided in the store path, those tags will be added to the certificate secret.
- If a "KmsKeyId" or "ReplicaRegions" entry parameter is provided, it is applied to both the certificate secret and the \<alias\>-pw secret.
- When an existing certificate is replaced, the \<alias\>-pw secret is updated with the new JKS password.

#### Example
Enrolling a certificate with the alias "mycert" into a AWSSMJKS certificate store that has a store path value of "us-east-2 [prefix="devteam"]" will result in the following..
Expand Down
2 changes: 2 additions & 0 deletions docsource/awssmpfx.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ In summary; supplying a name prefix or tag name and value as part of a certifica
- A tag will be added to the first secret called "PasswordSecret" and will contain the name of the secret containing the password.
- If a "prefix" is defined in the store path, it will be prepended to the names of both secrets.
- If a "TagName" and optional "TagValue" are provided in the store path, those tags will be added to the certificate secret.
- If a "KmsKeyId" or "ReplicaRegions" entry parameter is provided, it is applied to both the certificate secret and the \<alias\>-pw secret.
- When an existing certificate is replaced, the \<alias\>-pw secret is updated with the new PFX password.

#### Example
Enrolling a certificate with the alias "mycert" into a AWSSMPFX certificate store that has a store path value of "us-east-2 [prefix="devteam"]" will result in the following..
Expand Down
2 changes: 1 addition & 1 deletion integration-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"$schema": "https://keyfactor.github.io/v2/integration-manifest-schema.json",
"description": "This orchestrator extension allows managing Certificates stored in AWS Secrets Manager",
"integration_type": "orchestrator",
"link_github": true,
"name": "AWS Secrets Manager",
"link_github": true,
"release_dir": "AwsSecretsManager/bin/Release",
"release_project": "AwsSecretsManager/AwsSecretsManager.csproj",
"status": "prototype",
Expand Down
Loading