130 lines
5.5 KiB
C#
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);
|