File: LocalDeveloperCertificateStore.cs
Web Access
Project: src\src\Aspire.Hosting.Azure.EntraId\Aspire.Hosting.Azure.EntraId.csproj (Aspire.Hosting.Azure.EntraId)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Security.AccessControl;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Security.Principal;
using Aspire.Hosting.ApplicationModel;
using Aspire.Shared;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.Azure;
 
/// <summary>
/// Persists one short-lived signing certificate per registration in the AppHost's store.
/// </summary>
internal sealed class LocalDeveloperCertificateStore(IAspireStore store, TimeProvider timeProvider, ILogger<LocalDeveloperCertificateStore> logger)
{
    internal string GetPath(string resourceName) => Path.Combine(store.BasePath, "dev-certs", "Entra", $"{resourceName}.pfx");
 
    public async Task<LocalDeveloperCertificate> GetCertificateAsync(string resourceName, CancellationToken cancellationToken)
    {
        var path = GetPath(resourceName);
        var directory = Path.GetDirectoryName(path)!;
        DirectoryHelper.CreateWithOwnerOnlyPermissions(directory);
        if (OperatingSystem.IsWindows())
        {
            using var identity = WindowsIdentity.GetCurrent();
            var security = new DirectorySecurity();
            security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
            security.AddAccessRule(new FileSystemAccessRule(
                identity.User!, FileSystemRights.FullControl,
                InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
                PropagationFlags.None, AccessControlType.Allow));
            new DirectoryInfo(directory).SetAccessControl(security);
        }
 
        // File sharing coordinates separate AppHost processes as well as threads. The lock file is stable;
        // deleting it would let another process lock a different inode while a waiter holds the original.
        using var fileLock = await AcquireLockAsync(path + ".lock", cancellationToken).ConfigureAwait(false);
        var temporaryPath = path + ".tmp";
        if (File.Exists(temporaryPath))
        {
            File.Delete(temporaryPath);
        }
 
        var now = timeProvider.GetUtcNow();
        if (File.Exists(path))
        {
            ProtectFile(path);
            var bytes = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
            try
            {
                // macOS doesn't support ephemeral PFX imports and throws PlatformNotSupportedException; there the
                // key is imported into a temporary keychain that is cleaned up when the certificate is disposed.
                var keyStorageFlags = OperatingSystem.IsMacOS() ? X509KeyStorageFlags.DefaultKeySet : X509KeyStorageFlags.EphemeralKeySet;
                using var certificate = X509CertificateLoader.LoadPkcs12(bytes, null, keyStorageFlags);
                using var key = certificate.GetRSAPrivateKey();
                // Exercise the private key rather than trusting HasPrivateKey on a damaged/inaccessible key.
                _ = key?.SignData([0], HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
                // NotBefore/NotAfter are local times. Compare UTC values so a daylight-saving transition inside
                // the validity period doesn't make a 48-hour certificate look longer than it is.
                var notBefore = certificate.NotBefore.ToUniversalTime();
                var notAfter = certificate.NotAfter.ToUniversalTime();
                if (key is { KeySize: >= 2048 } &&
                    certificate.Subject == $"CN=Aspire Entra {resourceName}" &&
                    certificate.Issuer == certificate.Subject &&
                    notAfter - notBefore <= TimeSpan.FromDays(2) &&
                    notBefore <= now.UtcDateTime &&
                    notAfter > now.AddDays(1).UtcDateTime)
                {
                    return new LocalDeveloperCertificate(certificate, bytes);
                }
            }
            catch (CryptographicException)
            {
                logger.LogWarning("The local developer certificate for {ResourceName} could not be loaded. Generating a replacement.", resourceName);
            }
            finally
            {
                CryptographicOperations.ZeroMemory(bytes);
            }
 
            logger.LogInformation("Replacing the local developer certificate for {ResourceName} because it is incompatible or has one day or less remaining.", resourceName);
        }
 
        using var generated = CertificateFactory.CreateSelfSignedCertificate(
            new X500DistinguishedName($"CN=Aspire Entra {resourceName}"),
            [new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: true)],
            now,
            now.AddDays(2));
        var pfx = generated.Export(X509ContentType.Pfx);
        try
        {
            var fileOptions = new FileStreamOptions
            {
                Mode = FileMode.Create,
                Access = FileAccess.Write,
                Share = FileShare.None,
                Options = FileOptions.Asynchronous
            };
            if (!OperatingSystem.IsWindows())
            {
                fileOptions.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
            }
 
            using (var file = new FileStream(temporaryPath, fileOptions))
            {
                ProtectFile(temporaryPath);
                await file.WriteAsync(pfx, cancellationToken).ConfigureAwait(false);
                await file.FlushAsync(cancellationToken).ConfigureAwait(false);
                file.Flush(flushToDisk: true);
            }
 
            cancellationToken.ThrowIfCancellationRequested();
            // Replace the same file atomically. Retain the new material on Graph failure so retries use the
            // same public key, and never leave a growing collection of private-key generations on disk.
            File.Move(temporaryPath, path, overwrite: true);
            return new LocalDeveloperCertificate(generated, pfx);
        }
        finally
        {
            CryptographicOperations.ZeroMemory(pfx);
            File.Delete(temporaryPath);
        }
    }
 
    private static void ProtectFile(string path)
    {
        if (OperatingSystem.IsWindows())
        {
            using var identity = WindowsIdentity.GetCurrent();
            var security = new FileSecurity();
            security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
            security.AddAccessRule(new FileSystemAccessRule(identity.User!, FileSystemRights.FullControl, AccessControlType.Allow));
            new FileInfo(path).SetAccessControl(security);
        }
        else
        {
            File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
        }
    }
 
    private static async Task<FileStream> AcquireLockAsync(string path, CancellationToken cancellationToken)
    {
        while (true)
        {
            cancellationToken.ThrowIfCancellationRequested();
            try
            {
                return new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
            }
            // Retry sharing/lock violations and Unix EAGAIN/EWOULDBLOCK, not arbitrary I/O failures.
            catch (IOException ex) when ((ex.HResult & 0xffff) is 32 or 33 ||
                (!OperatingSystem.IsWindows() && (ex.HResult & 0xffff) is 11 or 35))
            {
                await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken).ConfigureAwait(false);
            }
        }
    }
}
 
/// <summary>
/// Holds the matching public provisioning value and secret runtime credential.
/// </summary>
internal sealed class LocalDeveloperCertificate
{
    public LocalDeveloperCertificate(X509Certificate2 certificate, byte[] pfx)
    {
        Base64EncodedValue = Convert.ToBase64String(pfx);
        PublicCertificate = Convert.ToBase64String(certificate.RawData);
        Thumbprint = Convert.ToBase64String(certificate.GetCertHash());
        NotBefore = new DateTimeOffset(certificate.NotBefore.ToUniversalTime());
        NotAfter = new DateTimeOffset(certificate.NotAfter.ToUniversalTime());
    }
 
    public string Base64EncodedValue { get; }
    public string PublicCertificate { get; }
    public string Thumbprint { get; }
    public DateTimeOffset NotBefore { get; }
    public DateTimeOffset NotAfter { get; }
}