using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using SharepointToolbox.Web.Infrastructure.Auth; namespace SharepointToolbox.Web.Services.Auth; /// /// Creates a 2048-bit RSA self-signed certificate valid for two years, persists its private /// key (PFX) through , and returns the public certificate so /// the caller can attach it to the Entra app registration as a sign-in credential. /// public class CertProvisioningService : ICertProvisioningService { private readonly IAppOnlyCertStore _certStore; public CertProvisioningService(IAppOnlyCertStore certStore) { _certStore = certStore; } public async Task GenerateAndStoreAsync( string profileId, string subjectName, CancellationToken ct = default) { // X.509 validity is stored at whole-second precision (ASN.1 has no sub-second field). // Truncate here so the keyCredential start/endDateTime we send to Graph match the // certificate's embedded validity exactly — otherwise the JSON endDateTime carries // a fractional second that lands *after* the cert's NotAfter and Graph rejects it // with KeyCredentialsInvalidEndDate. var notBefore = TruncateToSecond(DateTimeOffset.UtcNow.AddMinutes(-5)); var notAfter = notBefore.AddYears(2); using var rsa = RSA.Create(2048); var req = new CertificateRequest( $"CN={Sanitize(subjectName)}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); req.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false)); req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false)); using var cert = req.CreateSelfSigned(notBefore, notAfter); // Transient password only protects the in-memory PFX handoff to the store, which // re-exports it password-less and encrypts at rest with Data Protection. var transientPwd = Convert.ToBase64String(RandomNumberGenerator.GetBytes(24)); var pfxBytes = cert.Export(X509ContentType.Pkcs12, transientPwd); var thumbprint = await _certStore.SaveAsync(profileId, pfxBytes, transientPwd, ct); var publicBase64 = Convert.ToBase64String(cert.Export(X509ContentType.Cert)); return new CertProvisioningResult(thumbprint, publicBase64, notBefore, notAfter); } private static DateTimeOffset TruncateToSecond(DateTimeOffset value) => new(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond), value.Offset); // CN cannot contain characters that break the X.500 distinguished name. private static string Sanitize(string name) { var cleaned = name.Replace(",", " ").Replace("=", " ").Replace("\"", " ").Trim(); return string.IsNullOrEmpty(cleaned) ? "SP Toolbox" : cleaned; } }