server-monitor-manager/src/ServerMonitorManager.Control/CertificateAuthority.cs

130 lines
5.5 KiB
C#

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<ControlOptions> _options;
public CertificateAuthority(IOptions<ControlOptions> 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);