Skip to content

Commit 7c16da2

Browse files
Add file-backed cert store so CertHelper works on Mac (#5257)
* Add file-backed cert store so CertHelper works on Mac On macOS the CurrentUser\My X509Store maps to the login Keychain, which cannot export a private key back out without an interactive password prompt. As a result the iphone/Mac queues bypassed CertHelper entirely and relied on hand-provisioned static PFX files that were never rotated. Introduce an ILocalCertStore abstraction with two implementations: - X509LocalCertStore: existing Windows/Linux behavior over the OS store. - FileLocalCertStore: persists PFX files (thumbprint-named) in a known directory on Mac, loaded Exportable so no Keychain round-trip is needed. A factory selects the implementation by OS. LocalCert now loads through the abstraction, and Program.Main rotates via ILocalCertStore.Rotate and exports the in-memory Key Vault certificates to stdout instead of round-tripping through the OS store. common.py's get_certificates now runs CertHelper on all platforms (removing the Mac static-file path), so Mac hosts get the same bootstrap + Key Vault rotation as Linux. Update LocalCertTests to mock ILocalCertStore and add FileLocalCertStore round-trip tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review: reliable X509 rotation and restrict Mac key files - X509LocalCertStore now opens the store read-write by default and no longer re-opens/closes it in Rotate, so rotation works reliably on Windows/Linux and reads remain valid. - FileLocalCertStore restricts written PFX files to owner read/write (0600) on macOS so the private keys are not left world-readable. Note: the reviewer's suggestion to load PFXs with EphemeralKeySet is not applied because EphemeralKeySet throws PlatformNotSupportedException on macOS, which is the only platform that uses FileLocalCertStore. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent df57ee9 commit 7c16da2

6 files changed

Lines changed: 272 additions & 45 deletions

File tree

scripts/performance/common.py

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -210,24 +210,17 @@ def retry_on_exception(
210210

211211
def get_certificates() -> list[str]:
212212
'''
213-
Gets the certificates from the certhelper tool and on Mac uses find-certificate.
213+
Gets the certificates from the certhelper tool. CertHelper handles per-OS certificate
214+
storage internally (X509 store on Windows/Linux, file-backed store on Mac).
214215
'''
215-
if ismac():
216-
certs: list[str] = []
217-
with open("/Users/helix-runner/certs/LabCert1.pfx", "rb") as f:
218-
certs.append(base64.b64encode(f.read()).decode())
219-
with open("/Users/helix-runner/certs/LabCert2.pfx", "rb") as f:
220-
certs.append(base64.b64encode(f.read()).decode())
221-
return certs
222-
else:
223-
cmd_line = [(os.path.join(str(helixpayload()), 'certhelper', "CertHelper%s" % extension()))]
224-
cert_helper = RunCommand(cmd_line, None, True, False, 0)
225-
try:
226-
return cert_helper.run_and_get_stdout().splitlines()
227-
except Exception as ex:
228-
getLogger().error("Failed to get certificates")
229-
getLogger().error('{0}: {1}'.format(type(ex), str(ex)))
230-
return []
216+
cmd_line = [(os.path.join(str(helixpayload()), 'certhelper', "CertHelper%s" % extension()))]
217+
cert_helper = RunCommand(cmd_line, None, True, False, 0)
218+
try:
219+
return cert_helper.run_and_get_stdout().splitlines()
220+
except Exception as ex:
221+
getLogger().error("Failed to get certificates")
222+
getLogger().error('{0}: {1}'.format(type(ex), str(ex)))
223+
return []
231224

232225

233226
def __write_pipeline_variable(name: str, value: str):
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
using System.Runtime.InteropServices;
2+
using System.Security.Cryptography.X509Certificates;
3+
4+
namespace CertHelper;
5+
6+
/// <summary>
7+
/// Abstraction over where the local perf certificates live. On Windows and Linux this is
8+
/// backed by the CurrentUser\My <see cref="X509Store"/>. On macOS the CurrentUser\My store maps
9+
/// to the login Keychain, which cannot export a private key back out without an interactive
10+
/// password prompt, so a file-backed store is used instead.
11+
/// </summary>
12+
public interface ILocalCertStore
13+
{
14+
/// <summary>
15+
/// Returns the certificates currently persisted in the local store.
16+
/// </summary>
17+
X509Certificate2Collection GetCertificates();
18+
19+
/// <summary>
20+
/// Replaces <paramref name="certsToRemove"/> with <paramref name="certsToAdd"/> in the local store.
21+
/// </summary>
22+
void Rotate(X509Certificate2Collection certsToRemove, X509Certificate2Collection certsToAdd);
23+
}
24+
25+
/// <summary>
26+
/// Picks the appropriate <see cref="ILocalCertStore"/> for the current operating system.
27+
/// </summary>
28+
public static class LocalCertStoreFactory
29+
{
30+
public static ILocalCertStore Create()
31+
{
32+
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
33+
{
34+
return new FileLocalCertStore();
35+
}
36+
37+
return new X509LocalCertStore();
38+
}
39+
}
40+
41+
/// <summary>
42+
/// <see cref="ILocalCertStore"/> backed by the CurrentUser\My <see cref="X509Store"/>.
43+
/// Used on Windows and Linux where the managed store fully supports adding certificates with
44+
/// private keys and exporting them back out.
45+
/// </summary>
46+
public class X509LocalCertStore : ILocalCertStore
47+
{
48+
private readonly IX509Store _store;
49+
50+
public X509LocalCertStore(IX509Store? store = null)
51+
{
52+
// Open read-write by default so rotation works. TestableX509Store opens the store in its
53+
// constructor, so GetCertificates and Rotate both operate on the same already-open handle.
54+
_store = store ?? new TestableX509Store(OpenFlags.ReadWrite);
55+
}
56+
57+
public X509Certificate2Collection GetCertificates()
58+
{
59+
return _store.Certificates;
60+
}
61+
62+
public void Rotate(X509Certificate2Collection certsToRemove, X509Certificate2Collection certsToAdd)
63+
{
64+
var store = _store.GetX509Store();
65+
store.RemoveRange(certsToRemove);
66+
store.AddRange(certsToAdd);
67+
}
68+
}
69+
70+
/// <summary>
71+
/// <see cref="ILocalCertStore"/> that persists certificates as PFX files in a known directory.
72+
/// Used on macOS where the OS certificate store (login Keychain) cannot export a private key
73+
/// without an interactive password prompt. Files are named by thumbprint so that the on-disk
74+
/// state can be reconciled exactly with the desired set on rotation.
75+
/// </summary>
76+
public class FileLocalCertStore : ILocalCertStore
77+
{
78+
// Matches the directory the perf Mac hosts historically used for pre-provisioned certificates.
79+
public const string DefaultDirectory = "/Users/helix-runner/certs";
80+
81+
private readonly string _directory;
82+
83+
public FileLocalCertStore(string? directory = null)
84+
{
85+
_directory = directory ?? DefaultDirectory;
86+
}
87+
88+
public X509Certificate2Collection GetCertificates()
89+
{
90+
var certificates = new X509Certificate2Collection();
91+
if (!Directory.Exists(_directory))
92+
{
93+
return certificates;
94+
}
95+
96+
foreach (var file in Directory.EnumerateFiles(_directory, "*.pfx"))
97+
{
98+
try
99+
{
100+
var bytes = File.ReadAllBytes(file);
101+
#if NET9_0_OR_GREATER
102+
certificates.Add(X509CertificateLoader.LoadPkcs12(bytes, "", X509KeyStorageFlags.Exportable));
103+
#else
104+
certificates.Add(new X509Certificate2(bytes, "", X509KeyStorageFlags.Exportable));
105+
#endif
106+
}
107+
catch (Exception ex)
108+
{
109+
// Skip files that cannot be loaded; they will be treated as missing and trigger bootstrap/rotation.
110+
Console.Error.WriteLine($"Failed to load certificate from {file}: {ex.Message}");
111+
}
112+
}
113+
114+
return certificates;
115+
}
116+
117+
public void Rotate(X509Certificate2Collection certsToRemove, X509Certificate2Collection certsToAdd)
118+
{
119+
Directory.CreateDirectory(_directory);
120+
121+
// Clear the existing PFX files so the on-disk state matches the desired set exactly.
122+
foreach (var file in Directory.EnumerateFiles(_directory, "*.pfx"))
123+
{
124+
File.Delete(file);
125+
}
126+
127+
foreach (var cert in certsToAdd)
128+
{
129+
var path = Path.Combine(_directory, $"{cert.Thumbprint}.pfx");
130+
File.WriteAllBytes(path, cert.Export(X509ContentType.Pfx));
131+
RestrictToOwner(path);
132+
}
133+
}
134+
135+
// Private keys are written to disk as unprotected PFX files. On macOS (the only platform that
136+
// uses this file-backed store) restrict them to owner read/write so the keys are not exposed if
137+
// the directory permissions or umask are permissive.
138+
private static void RestrictToOwner(string path)
139+
{
140+
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
141+
{
142+
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
143+
}
144+
}
145+
}

src/tools/CertHelper/LocalCert.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,19 @@ public class LocalCert : ILocalCert
1313
{
1414
public X509Certificate2Collection Certificates { get; set; }
1515
public bool RequiresBootstrap { get; private set; }
16-
internal IX509Store LocalMachineCerts { get; set; }
16+
internal ILocalCertStore LocalCertStore { get; set; }
1717

18-
public LocalCert(IX509Store? store = null)
18+
public LocalCert(ILocalCertStore? store = null)
1919
{
20-
LocalMachineCerts = store ?? new TestableX509Store();
20+
LocalCertStore = store ?? LocalCertStoreFactory.Create();
2121
Certificates = new X509Certificate2Collection();
2222
RequiresBootstrap = false;
2323
GetLocalCerts();
2424
}
2525

2626
private void GetLocalCerts()
2727
{
28-
foreach (var cert in LocalMachineCerts.Certificates.Find(X509FindType.FindBySubjectName, "dotnetperf.microsoft.com", false))
28+
foreach (var cert in LocalCertStore.GetCertificates().Find(X509FindType.FindBySubjectName, "dotnetperf.microsoft.com", false))
2929
{
3030
if (cert.Subject == "CN=dotnetperf.microsoft.com")
3131
{

src/tools/CertHelper/Program.cs

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,18 @@ internal class Program
1515

1616
static async Task<int> Main(string[] args)
1717
{
18+
var certStore = LocalCertStoreFactory.Create();
19+
X509Certificate2Collection? certsToExport = null;
1820
try
1921
{
20-
var kvc = new KeyVaultCert();
22+
var localCerts = new LocalCert(certStore);
23+
var kvc = new KeyVaultCert(localCerts: localCerts);
2124
await kvc.LoadKeyVaultCertsAsync();
2225
if (kvc.ShouldRotateCerts())
2326
{
24-
using (var localMachineCerts = new X509Store(StoreName.My, StoreLocation.CurrentUser))
25-
{
26-
localMachineCerts.Open(OpenFlags.ReadWrite);
27-
localMachineCerts.RemoveRange(kvc.LocalCerts.Certificates);
28-
localMachineCerts.AddRange(kvc.KeyVaultCertificates);
29-
}
27+
certStore.Rotate(kvc.LocalCerts.Certificates, kvc.KeyVaultCertificates);
3028
}
29+
certsToExport = kvc.KeyVaultCertificates;
3130
var bcc = new BlobContainerClient(new Uri("https://pvscmdupload.blob.core.windows.net/certstatus"),
3231
new ClientCertificateCredential(TENANT_ID, CERT_CLIENT_ID, kvc.KeyVaultCertificates.First(), new() {SendCertificateChain = true}));
3332
var currentKeyValutCertThumbprints = "";
@@ -55,12 +54,14 @@ static async Task<int> Main(string[] args)
5554
Console.Error.WriteLine(ex.StackTrace);
5655
}
5756

58-
using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser, OpenFlags.ReadOnly))
57+
// Export the current certificates to stdout. Prefer the in-memory Key Vault certificates
58+
// (loaded as exportable) so we never round-trip through the OS store, which cannot export
59+
// private keys on macOS. If Key Vault auth failed above, fall back to whatever is currently
60+
// persisted locally so callers can still attempt to use existing certificates.
61+
var exportSource = certsToExport ?? certStore.GetCertificates();
62+
foreach (var cert in exportSource.Find(X509FindType.FindBySubjectName, "dotnetperf.microsoft.com", false))
5963
{
60-
foreach(var cert in store.Certificates.Find(X509FindType.FindBySubjectName, "dotnetperf.microsoft.com", false))
61-
{
62-
Console.WriteLine(Convert.ToBase64String(cert.Export(X509ContentType.Pfx)));
63-
}
64+
Console.WriteLine(Convert.ToBase64String(cert.Export(X509ContentType.Pfx)));
6465
}
6566
return 0;
6667
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
using System.Security.Cryptography;
2+
using System.Security.Cryptography.X509Certificates;
3+
using Xunit;
4+
5+
namespace CertHelper.Tests;
6+
7+
public class FileLocalCertStoreTests : IDisposable
8+
{
9+
private readonly string _directory;
10+
11+
public FileLocalCertStoreTests()
12+
{
13+
_directory = Path.Combine(Path.GetTempPath(), "certhelper-tests-" + Guid.NewGuid().ToString("N"));
14+
}
15+
16+
public void Dispose()
17+
{
18+
if (Directory.Exists(_directory))
19+
{
20+
Directory.Delete(_directory, recursive: true);
21+
}
22+
}
23+
24+
private static X509Certificate2 MakeCert(string subject = "CN=dotnetperf.microsoft.com")
25+
{
26+
using var rsa = RSA.Create();
27+
var req = new CertificateRequest(subject, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
28+
return req.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(1));
29+
}
30+
31+
[Fact]
32+
public void GetCertificates_ShouldReturnEmpty_WhenDirectoryMissing()
33+
{
34+
var store = new FileLocalCertStore(_directory);
35+
36+
var result = store.GetCertificates();
37+
38+
Assert.Empty(result);
39+
}
40+
41+
[Fact]
42+
public void Rotate_ShouldWriteCertificates_ThatCanBeReloaded()
43+
{
44+
var store = new FileLocalCertStore(_directory);
45+
var cert1 = MakeCert();
46+
var cert2 = MakeCert();
47+
var toAdd = new X509Certificate2Collection { cert1, cert2 };
48+
49+
store.Rotate(new X509Certificate2Collection(), toAdd);
50+
51+
var reloaded = store.GetCertificates();
52+
Assert.Equal(2, reloaded.Count);
53+
var thumbprints = reloaded.Cast<X509Certificate2>().Select(c => c.Thumbprint).ToHashSet();
54+
Assert.Contains(cert1.Thumbprint, thumbprints);
55+
Assert.Contains(cert2.Thumbprint, thumbprints);
56+
}
57+
58+
[Fact]
59+
public void Rotate_ShouldReplaceExistingCertificates()
60+
{
61+
var store = new FileLocalCertStore(_directory);
62+
var oldCert = MakeCert();
63+
store.Rotate(new X509Certificate2Collection(), new X509Certificate2Collection { oldCert });
64+
65+
var newCert = MakeCert();
66+
store.Rotate(new X509Certificate2Collection { oldCert }, new X509Certificate2Collection { newCert });
67+
68+
var reloaded = store.GetCertificates();
69+
Assert.Single(reloaded);
70+
Assert.Equal(newCert.Thumbprint, reloaded[0].Thumbprint);
71+
}
72+
73+
[Fact]
74+
public void Rotate_ReloadedCertificate_ShouldBeExportableWithPrivateKey()
75+
{
76+
var store = new FileLocalCertStore(_directory);
77+
var cert = MakeCert();
78+
79+
store.Rotate(new X509Certificate2Collection(), new X509Certificate2Collection { cert });
80+
81+
var reloaded = store.GetCertificates();
82+
Assert.Single(reloaded);
83+
Assert.True(reloaded[0].HasPrivateKey);
84+
// Must be exportable so Program.Main can emit the PFX to stdout without an OS keystore round-trip.
85+
var exported = reloaded[0].Export(X509ContentType.Pfx);
86+
Assert.NotEmpty(exported);
87+
}
88+
}

0 commit comments

Comments
 (0)