using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Microsoft.Extensions.Options; using ServerMonitorManager.Core; namespace ServerMonitorManager.Control; public sealed class CertificateAuthority : IDisposable { private readonly X509Certificate2 _issuer; private readonly IOptions _options; public CertificateAuthority(IOptions options) { _options = options; var value = options.Value; _issuer = X509CertificateLoader.LoadPkcs12FromFile( value.CertificateAuthorityPath, value.CertificateAuthorityPassword, X509KeyStorageFlags.EphemeralKeySet); if (!_issuer.HasPrivateKey) { throw new InvalidOperationException("Control CA certificate must contain its private key."); } } public X509Certificate2 PublicCertificate => _issuer; public IssuedCertificate IssueClientCertificate(string nodeId, string csrPem) { var request = CertificateRequest.LoadSigningRequestPem( csrPem, HashAlgorithmName.SHA256, CertificateRequestLoadOptions.UnsafeLoadCertificateExtensions); if (!string.Equals(request.SubjectName.Name, $"CN={nodeId}", StringComparison.Ordinal)) { throw new InvalidOperationException("CSR subject does not match the requested node id."); } if (request.CertificateExtensions.Count != 0) { throw new InvalidOperationException("CSR extensions are not accepted."); } request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, true)); request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension( new OidCollection { new("1.3.6.1.5.5.7.3.2") }, true)); request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); var serial = RandomNumberGenerator.GetBytes(20); var issuerNotBefore = new DateTimeOffset(_issuer.NotBefore.ToUniversalTime(), TimeSpan.Zero); var issuerNotAfter = new DateTimeOffset(_issuer.NotAfter.ToUniversalTime(), TimeSpan.Zero); var notBefore = DateTimeOffset.UtcNow.AddMinutes(-2) > issuerNotBefore ? DateTimeOffset.UtcNow.AddMinutes(-2) : issuerNotBefore; var requestedNotAfter = DateTimeOffset.UtcNow.AddDays(_options.Value.ClientCertificateDays); var notAfter = requestedNotAfter < issuerNotAfter ? requestedNotAfter : issuerNotAfter; if (notAfter <= notBefore) { throw new InvalidOperationException("Control CA certificate is expired or not yet valid."); } using var certificate = request.Create(_issuer, notBefore, notAfter, serial); return new IssuedCertificate( certificate.ExportCertificatePem(), _issuer.ExportCertificatePem(), certificate.Thumbprint, notAfter); } public ProvisioningExecutionGrant SignProvisioningExecutionGrant( ProvisioningJob job, SystemBaseInstallPlan plan, DateTimeOffset issuedAt, TimeSpan lifetime) { if (job.ActionType != "system.base-install" || job.SchemaVersion != 1 || job.State != ProvisioningJobStates.Running || !job.ConfirmationRequired || job.ConfirmedAt is null || lifetime <= TimeSpan.Zero || lifetime > ProvisioningExecutionGrantCodec.MaximumLifetime) { throw new InvalidOperationException( "Only a confirmed running base installation job can receive an execution grant."); } var expiresAt = issuedAt.Add(lifetime) < job.ExpiresAt ? issuedAt.Add(lifetime) : job.ExpiresAt; var issuedAtUnixSeconds = issuedAt.ToUnixTimeSeconds(); var expiresAtUnixSeconds = expiresAt.ToUnixTimeSeconds(); if (expiresAtUnixSeconds <= issuedAtUnixSeconds) { throw new InvalidOperationException("Provisioning job expires before a grant can be issued."); } var grant = new ProvisioningExecutionGrant( ProvisioningExecutionGrantCodec.ProtocolVersion, job.Id, job.NodeId, job.ActionType, job.SchemaVersion, ProvisioningExecutionGrantCodec.ComputePlanSha256(plan), issuedAtUnixSeconds, expiresAtUnixSeconds, Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16)), ProvisioningExecutionGrantCodec.SignatureAlgorithm, string.Empty); using var key = _issuer.GetECDsaPrivateKey(); if (key is not { KeySize: 256 }) { throw new InvalidOperationException( "Control CA must use an ECDSA P-256 key to sign provisioning execution grants."); } var signature = key.SignData( ProvisioningExecutionGrantCodec.CreateSigningPayload(grant), HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation); return grant with { Signature = ProvisioningExecutionGrantCodec.EncodeBase64Url(signature) }; } public void Dispose() => _issuer.Dispose(); } public sealed record IssuedCertificate( string CertificatePem, string CertificateAuthorityPem, string Thumbprint, DateTimeOffset ExpiresAt);