Skip to content

Commit b27fb47

Browse files
Implemented "SeparatePrivateKey" flag functionality; added unit tests
1 parent d8e5e17 commit b27fb47

15 files changed

Lines changed: 1412 additions & 605 deletions
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright 2026 Keyfactor
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// 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+
6+
using System;
7+
using System.Security.Cryptography.X509Certificates;
8+
using FluentAssertions;
9+
using Xunit;
10+
11+
namespace Keyfactor.Extensions.Orchestrators.AwsSecretsManager.Tests
12+
{
13+
/// <summary>
14+
/// Tests for the legacy CertUtilities.ConvertPfxToPem (the concatenated-PEM format used by
15+
/// AWSSMPEM without SeparatePrivateKey). The RSA/EC cases specifically guard against the
16+
/// Windows CNG export failure ("The requested operation is not supported") that occurs when
17+
/// exporting a private key imported from a PFX via the .NET key APIs.
18+
/// </summary>
19+
public class CertUtilitiesConvertPfxToPemTests
20+
{
21+
[Fact]
22+
public void ConvertPfxToPem_Rsa_ReturnsLeafAndPkcs8Key()
23+
{
24+
// Regression guard: before routing the key export through BouncyCastle, this threw
25+
// a CryptographicException on Windows for any PFX whose key originated from CNG.
26+
var pfx = TestCertFactory.CreateSelfSignedRsaPfxBase64();
27+
28+
var pem = CertUtilities.ConvertPfxToPem(pfx, TestCertFactory.Password);
29+
30+
pem.Should().Contain("-----BEGIN CERTIFICATE-----").And.Contain("-----END CERTIFICATE-----");
31+
pem.Should().Contain("-----BEGIN PRIVATE KEY-----").And.Contain("-----END PRIVATE KEY-----");
32+
pem.Should().NotContain("BEGIN RSA PRIVATE KEY", "the key must be PKCS#8");
33+
}
34+
35+
[Fact]
36+
public void ConvertPfxToPem_Ec_ReturnsPkcs8Key()
37+
{
38+
var pfx = TestCertFactory.CreateSelfSignedEcPfxBase64();
39+
40+
var pem = CertUtilities.ConvertPfxToPem(pfx, TestCertFactory.Password);
41+
42+
pem.Should().Contain("-----BEGIN PRIVATE KEY-----");
43+
pem.Should().NotContain("BEGIN EC PRIVATE KEY");
44+
}
45+
46+
[Fact]
47+
public void ConvertPfxToPem_KeyMatchesCertificate()
48+
{
49+
var pfx = TestCertFactory.CreateSelfSignedRsaPfxBase64("CN=pem-match");
50+
51+
var pem = CertUtilities.ConvertPfxToPem(pfx, TestCertFactory.Password);
52+
53+
// CreateFromPem throws if the key does not correspond to the certificate.
54+
using var rebuilt = X509Certificate2.CreateFromPem(pem, pem);
55+
rebuilt.HasPrivateKey.Should().BeTrue();
56+
rebuilt.Subject.Should().Contain("pem-match");
57+
}
58+
59+
[Fact]
60+
public void ConvertPfxToPem_ChainPfx_ReturnsLeafOnly()
61+
{
62+
// The legacy format is leaf + key only (no chain). This locks that contract and
63+
// distinguishes it from ConvertPfxToCertAndKeyPem, which includes the full chain.
64+
var pfx = TestCertFactory.CreateChainPfxBase64(out var leafSubject, out _);
65+
66+
var pem = CertUtilities.ConvertPfxToPem(pfx, TestCertFactory.Password);
67+
68+
var certs = new X509Certificate2Collection();
69+
certs.ImportFromPem(pem);
70+
71+
certs.Count.Should().Be(1, "the legacy PEM format includes only the leaf certificate");
72+
certs[0].Subject.Should().Be(leafSubject);
73+
}
74+
75+
[Fact]
76+
public void ConvertPfxToPem_InvalidBase64_Throws()
77+
{
78+
Action act = () => CertUtilities.ConvertPfxToPem("not-base64!!!", TestCertFactory.Password);
79+
80+
act.Should().Throw<ArgumentException>();
81+
}
82+
}
83+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Copyright 2026 Keyfactor
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// 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+
6+
using System;
7+
using System.Security.Cryptography.X509Certificates;
8+
using FluentAssertions;
9+
using Xunit;
10+
11+
namespace Keyfactor.Extensions.Orchestrators.AwsSecretsManager.Tests
12+
{
13+
/// <summary>
14+
/// Tests for CertUtilities.ConvertPfxToCertAndKeyPem, which backs the SeparatePrivateKey
15+
/// JSON format. Verifies PEM shape, PKCS#8 key header, key/cert correspondence, and the
16+
/// critical leaf-first chain ordering.
17+
/// </summary>
18+
public class CertUtilitiesSeparateKeyTests
19+
{
20+
[Fact]
21+
public void ConvertPfxToCertAndKeyPem_Rsa_ReturnsPemCertAndPkcs8Key()
22+
{
23+
var pfx = TestCertFactory.CreateSelfSignedRsaPfxBase64();
24+
25+
var (certPem, keyPem) = CertUtilities.ConvertPfxToCertAndKeyPem(pfx, TestCertFactory.Password);
26+
27+
certPem.Should().Contain("-----BEGIN CERTIFICATE-----").And.Contain("-----END CERTIFICATE-----");
28+
keyPem.Should().Contain("-----BEGIN PRIVATE KEY-----").And.Contain("-----END PRIVATE KEY-----");
29+
keyPem.Should().NotContain("BEGIN RSA PRIVATE KEY", "the key must be PKCS#8, matching the legacy PEM format");
30+
keyPem.Should().NotContain("ENCRYPTED", "the key is stored unencrypted (relies on KMS at rest)");
31+
}
32+
33+
[Fact]
34+
public void ConvertPfxToCertAndKeyPem_Ec_ReturnsPkcs8Key()
35+
{
36+
var pfx = TestCertFactory.CreateSelfSignedEcPfxBase64();
37+
38+
var (_, keyPem) = CertUtilities.ConvertPfxToCertAndKeyPem(pfx, TestCertFactory.Password);
39+
40+
keyPem.Should().Contain("-----BEGIN PRIVATE KEY-----");
41+
keyPem.Should().NotContain("BEGIN EC PRIVATE KEY", "EC keys also use the PKCS#8 header convention");
42+
}
43+
44+
[Fact]
45+
public void ConvertPfxToCertAndKeyPem_KeyMatchesCertificate()
46+
{
47+
var pfx = TestCertFactory.CreateSelfSignedRsaPfxBase64("CN=match-test");
48+
49+
var (certPem, keyPem) = CertUtilities.ConvertPfxToCertAndKeyPem(pfx, TestCertFactory.Password);
50+
51+
// CreateFromPem throws if the private key does not correspond to the certificate.
52+
using var rebuilt = X509Certificate2.CreateFromPem(certPem, keyPem);
53+
rebuilt.HasPrivateKey.Should().BeTrue();
54+
rebuilt.Subject.Should().Contain("match-test");
55+
}
56+
57+
[Fact]
58+
public void ConvertPfxToCertAndKeyPem_IncludesChain_LeafFirst()
59+
{
60+
var pfx = TestCertFactory.CreateChainPfxBase64(out var leafSubject, out var rootSubject);
61+
62+
var (certPem, _) = CertUtilities.ConvertPfxToCertAndKeyPem(pfx, TestCertFactory.Password);
63+
64+
var certs = new X509Certificate2Collection();
65+
certs.ImportFromPem(certPem);
66+
67+
certs.Count.Should().Be(2, "both the leaf and its issuer should be present in the certificate field");
68+
certs[0].Subject.Should().Be(leafSubject, "the leaf certificate must come first");
69+
certs[1].Subject.Should().Be(rootSubject, "the issuer must follow the leaf");
70+
}
71+
72+
[Fact]
73+
public void ConvertPfxToCertAndKeyPem_InvalidBase64_Throws()
74+
{
75+
Action act = () => CertUtilities.ConvertPfxToCertAndKeyPem("not-base64!!!", TestCertFactory.Password);
76+
77+
act.Should().Throw<ArgumentException>();
78+
}
79+
80+
[Fact]
81+
public void ConvertPfxToCertAndKeyPem_NullOrEmpty_Throws()
82+
{
83+
Action act = () => CertUtilities.ConvertPfxToCertAndKeyPem("", TestCertFactory.Password);
84+
85+
act.Should().Throw<ArgumentException>();
86+
}
87+
}
88+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Copyright 2026 Keyfactor
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// 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+
6+
using System.Reflection;
7+
using Amazon.SecretsManager.Model;
8+
using FluentAssertions;
9+
using Keyfactor.Extensions.Orchestrators.AwsSecretsManager.models;
10+
using Newtonsoft.Json;
11+
using Xunit;
12+
13+
namespace Keyfactor.Extensions.Orchestrators.AwsSecretsManager.Tests
14+
{
15+
/// <summary>
16+
/// Exercises the client's PEM-JSON write generators (used when AWSSMPEM has
17+
/// SeparatePrivateKey enabled). The generate methods are private and AWS-free, so they
18+
/// are invoked via reflection; the resulting SecretString is validated as the expected
19+
/// JSON document.
20+
/// </summary>
21+
public class ClientPemJsonWriteTests
22+
{
23+
[Fact]
24+
public void GenerateAddSecretPemJsonRequest_ProducesJsonSecretWithBothFields()
25+
{
26+
var jp = BuildJobParameters();
27+
28+
var req = InvokeGenerate<CreateSecretRequest>("GenerateAddSecretPemJsonRequest", jp);
29+
30+
req.Name.Should().Be("write-test");
31+
AssertValidPemSecretJson(req.SecretString);
32+
}
33+
34+
[Fact]
35+
public void GenerateUpdateSecretPemJsonRequest_ProducesJsonSecretWithBothFields()
36+
{
37+
var jp = BuildJobParameters();
38+
39+
var req = InvokeGenerate<UpdateSecretRequest>("GenerateUpdateSecretPemJsonRequest", jp);
40+
41+
req.SecretId.Should().Be("write-test");
42+
AssertValidPemSecretJson(req.SecretString);
43+
}
44+
45+
// ── helpers ────────────────────────────────────────────────────────
46+
47+
private static AwsSecretsManagerJobParameters BuildJobParameters()
48+
{
49+
var jp = new AwsSecretsManagerJobParameters { StoreType = "AWSSMPEM" };
50+
jp.StoreProperties.SeparatePrivateKey = true;
51+
jp.CertProperties.Alias = "write-test";
52+
jp.CertProperties.Contents = TestCertFactory.CreateSelfSignedRsaPfxBase64("CN=write-test");
53+
jp.CertProperties.PrivateKeyPassword = TestCertFactory.Password;
54+
return jp;
55+
}
56+
57+
private static T InvokeGenerate<T>(string methodName, AwsSecretsManagerJobParameters jp)
58+
{
59+
var client = new AwsSecretsManagerClient();
60+
var m = typeof(AwsSecretsManagerClient).GetMethod(
61+
methodName, BindingFlags.NonPublic | BindingFlags.Instance);
62+
m.Should().NotBeNull($"{methodName} must be reachable via reflection");
63+
return (T)m!.Invoke(client, new object[] { jp })!;
64+
}
65+
66+
private static void AssertValidPemSecretJson(string secretString)
67+
{
68+
var parsed = JsonConvert.DeserializeObject<PemSecret>(secretString);
69+
parsed.Should().NotBeNull();
70+
parsed!.Certificate.Should().Contain("-----BEGIN CERTIFICATE-----");
71+
parsed.PrivateKey.Should().Contain("-----BEGIN PRIVATE KEY-----");
72+
}
73+
}
74+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Copyright 2026 Keyfactor
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// 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+
6+
using System.Collections.Generic;
7+
using System.Linq;
8+
using System.Reflection;
9+
using Amazon.SecretsManager.Model;
10+
using FluentAssertions;
11+
using Keyfactor.Extensions.Orchestrators.AwsSecretsManager.Jobs;
12+
using Keyfactor.Extensions.Orchestrators.AwsSecretsManager.models;
13+
using Keyfactor.Orchestrators.Extensions;
14+
using Keyfactor.Orchestrators.Extensions.Interfaces;
15+
using Moq;
16+
using Newtonsoft.Json;
17+
using Xunit;
18+
19+
namespace Keyfactor.Extensions.Orchestrators.AwsSecretsManager.Tests
20+
{
21+
/// <summary>
22+
/// Verifies that inventory tolerates BOTH the legacy concatenated-PEM format and the
23+
/// new SeparatePrivateKey JSON format, so a format mismatch never drops a secret from
24+
/// the returned inventory set (which Command would otherwise interpret as a removal).
25+
/// </summary>
26+
public class InventorySeparateKeyTests
27+
{
28+
[Fact]
29+
public void ConvertSecretsPem_JsonSplitFormat_IsInventoried()
30+
{
31+
var secret = new AWSSecret
32+
{
33+
Name = "json-cert",
34+
ARN = "arn:aws:secretsmanager:us-east-1:123:secret:json-cert-AbCdEf",
35+
SecretString = BuildSeparateKeyJsonSecret("CN=json-cert"),
36+
Tags = new List<Tag>()
37+
};
38+
39+
var items = InvokeConvertPem(secret);
40+
41+
items.Should().HaveCount(1);
42+
items[0].Alias.Should().Be("json-cert");
43+
items[0].Certificates.Should().NotBeNullOrEmpty();
44+
items[0].PrivateKeyEntry.Should().BeTrue("the JSON document carries a private_key");
45+
}
46+
47+
[Fact]
48+
public void ConvertSecretsPem_MixedFormats_BothReturned()
49+
{
50+
var jsonSecret = new AWSSecret
51+
{
52+
Name = "json-cert",
53+
SecretString = BuildSeparateKeyJsonSecret("CN=json-cert"),
54+
Tags = new List<Tag>()
55+
};
56+
var pemSecret = new AWSSecret
57+
{
58+
Name = "pem-cert",
59+
SecretString = BuildRawPemSecret("CN=pem-cert"),
60+
Tags = new List<Tag>()
61+
};
62+
63+
var items = InvokeConvertPem(jsonSecret, pemSecret);
64+
65+
items.Select(i => i.Alias).Should().BeEquivalentTo(new[] { "json-cert", "pem-cert" });
66+
}
67+
68+
[Fact]
69+
public void ConvertSecretsPem_JsonSplitWithTags_SerializesTagsAsJsonString()
70+
{
71+
var secret = new AWSSecret
72+
{
73+
Name = "json-cert",
74+
SecretString = BuildSeparateKeyJsonSecret("CN=json-cert"),
75+
Tags = new List<Tag> { new Tag { Key = "Environment", Value = "Prod" } }
76+
};
77+
78+
var item = InvokeConvertPem(secret).Single();
79+
80+
item.Parameters.Should().ContainKey("CertificateTags");
81+
item.Parameters["CertificateTags"].Should().BeOfType<string>(
82+
"the CertificateTags regression contract still applies in the JSON format");
83+
84+
var tags = JsonConvert.DeserializeObject<Dictionary<string, string>>(
85+
(string)item.Parameters["CertificateTags"]);
86+
tags.Should().NotBeNull();
87+
tags!["Environment"].Should().Be("Prod");
88+
}
89+
90+
// ── helpers ────────────────────────────────────────────────────────
91+
92+
private static string BuildSeparateKeyJsonSecret(string subject)
93+
{
94+
var pfx = TestCertFactory.CreateSelfSignedRsaPfxBase64(subject);
95+
var (certPem, keyPem) = CertUtilities.ConvertPfxToCertAndKeyPem(pfx, TestCertFactory.Password);
96+
return JsonConvert.SerializeObject(new PemSecret { Certificate = certPem, PrivateKey = keyPem });
97+
}
98+
99+
private static string BuildRawPemSecret(string subject)
100+
{
101+
// Build a legacy-style concatenated PEM (cert + key) without going through the
102+
// .NET/CNG export path, which can fail on Windows for keys imported from a PFX.
103+
var pfx = TestCertFactory.CreateSelfSignedRsaPfxBase64(subject);
104+
var (certPem, keyPem) = CertUtilities.ConvertPfxToCertAndKeyPem(pfx, TestCertFactory.Password);
105+
return certPem + "\n" + keyPem;
106+
}
107+
108+
private static List<CurrentInventoryItem> InvokeConvertPem(params AWSSecret[] secrets)
109+
{
110+
var resolverMock = new Mock<IPAMSecretResolver>();
111+
resolverMock.Setup(r => r.Resolve(It.IsAny<string>())).Returns<string>(s => s);
112+
113+
var inv = new TestableInventory(resolverMock.Object)
114+
{
115+
PublicJobParameters = new AwsSecretsManagerJobParameters { StoreType = "AWSSMPEM" }
116+
};
117+
118+
var m = typeof(Inventory).GetMethod(
119+
"ConvertSecretsPem",
120+
BindingFlags.NonPublic | BindingFlags.Instance);
121+
m.Should().NotBeNull("ConvertSecretsPem must be reachable via reflection");
122+
123+
var result = m!.Invoke(inv, new object[] { secrets.ToList() });
124+
125+
var tuple = (System.Runtime.CompilerServices.ITuple)result!;
126+
return (List<CurrentInventoryItem>)tuple[0]!;
127+
}
128+
}
129+
}

0 commit comments

Comments
 (0)