feat: client certificate lifecycle management, auto-renewal, and CA rotation guide

This commit is contained in:
ochenstarik-ui 2026-08-09 22:34:18 +07:00
parent df4dfd177a
commit 25a93f7b35
14 changed files with 832 additions and 9 deletions

46
TEST_EVIDENCE.md Normal file
View file

@ -0,0 +1,46 @@
# Test Evidence - Client Certificate Lifecycle Management
## 1. Automated Test Execution
Command executed:
```powershell
dotnet test tests/ServerMonitorManager.Control.Tests/ServerMonitorManager.Control.Tests.csproj --configuration Release
```
Output summary:
```text
Тестовый запуск для C:\Users\Ochenstarik\projects\smm-antigravity\tests\ServerMonitorManager.Control.Tests\bin\Release\net10.0\ServerMonitorManager.Control.Tests.dll (.NETCoreApp,Version=v10.0)
Пройден! : не пройдено 0, пройдено 101, пропущено 0, всего 101, длительность 11 s. - ServerMonitorManager.Control.Tests.dll (net10.0)
```
Includes test cases in `CertificateLifecycleTests.cs`:
1. `CertWithLessThanOneThirdRemainingIsRenewed_AndOldCertReplaced` - PASS
2. `CertWithSufficientRemainingLifetime_IsNotRenewed` - PASS
3. `HubUnavailable_AgentContinuesUsingExistingCert_MetricsPreserved` - PASS
4. `RevokedCert_CannotBeRenewed` - PASS
5. `RenewalRequest_WithDifferentNodeId_IsRejected` - PASS
6. `InterruptedPfxReplacement_ActiveCertRemainsIntact` - PASS
7. `OutOfRangeClientCertificateDays_ValidationFailsOnStart` - PASS
---
## 2. Trimmed Self-Contained Binary Publish & Startup Validation
Publish Command:
```powershell
dotnet publish src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj -c Release -r win-x64 --self-contained -p:PublishTrimmed=true
```
Validation Test Command with out-of-range option (`ClientCertificateDays = 999`):
```powershell
.\src\ServerMonitorManager.Control\bin\Release\net10.0\win-x64\publish\ochenstarik-smm-control.exe --Control:ClientCertificateDays 999
```
Output:
```text
Unhandled exception. Microsoft.Extensions.Options.OptionsValidationException: Invalid Control paths, heartbeat, retention, maintenance, expiration, reconciliation, or backup settings.
at Microsoft.Extensions.Options.OptionsFactory`1.Create(String name)
at Program.<Main>$(String[] args) in C:\Users\Ochenstarik\projects\smm-antigravity\src\ServerMonitorManager.Control\Program.cs:line 127
```
Result: Startup validation cleanly catches values out of range [1..90] on self-contained trimmed binary.

View file

@ -0,0 +1,87 @@
# Control CA Certificate Rotation Procedure
This document defines the operational workflow for rotating the Root/Intermediate Certificate Authority (Control CA) for ServerMonitorManager.
## 1. Rationale & Policy
Control CA rotation is required in the following events:
- **Scheduled Rollover**: Periodic proactive rotation before CA expiration.
- **Key Compromise**: Suspected or confirmed private key exposure.
- **Cryptographic Upgrade**: Transitioning to stronger key algorithms or curves (e.g., ECDSA P-256).
> [!IMPORTANT]
> **Two-Person Policy Enforcement**:
> In accordance with [`docs/approval-policies.md`](file:///C:/Users/Ochenstarik/projects/smm-antigravity/docs/approval-policies.md), initiating or finalizing a Control CA rotation requires a `two_person` confirmation policy where an operator request must be confirmed by a distinct authorized reviewer before executing CA replacement in production.
---
## 2. Step-by-Step Rotation Procedure
```mermaid
sequenceDiagram
autonumber
participant Admin as Operator (Desktop/CLI)
participant Hub as Control Hub
participant Agent as Agent Node
Note over Hub: Phase 1: Generate New CA
Admin->>Hub: Generate new CA keypair (control-ca-next.pfx)
Note over Hub, Agent: Phase 2: Dual-Trust Distribution
Hub->>Agent: Distribute Combined CA Bundle (old CA + new CA)
Note over Agent: Phase 3: Client Certificate Re-issuance
Agent->>Hub: Submit CSR signed with active mTLS
Hub->>Agent: Issue new client cert signed by new CA
Agent->>Agent: Atomically replace agent.pfx (0600 permissions)
Note over Hub: Phase 4: Old CA Retirement
Admin->>Hub: Revoke/Archive Old CA; Enforce New CA only
```
### Phase 1: New CA Keypair Generation
Generate a new ECDSA P-256 Control CA certificate and PFX bundle:
```bash
# Generate new CA private key & cert
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-days 3650 -nodes -keyout control-ca-next.key -out control-ca-next.crt \
-subj "/CN=ServerMonitorManager Control CA v2"
# Package into PFX format
openssl pkcs12 -export -out control-ca-next.pfx \
-inkey control-ca-next.key -in control-ca-next.crt -passout pass:
```
### Phase 2: Dual-Trust Distribution
Append `control-ca-next.crt` to the active CA trust bundle on Control Hub and Agents:
- Control Hub configuration `CertificateAuthorityPath` points to dual-trust bundle.
- Agents update their `control-ca.crt` trust store to trust both old and new CA roots.
### Phase 3: Agent & Operator Certificate Re-issuance
Force client certificate renewal for all enrolled agents and operators:
1. Agent detects new CA bundle during periodic `EnsureCertificateRenewedAsync`.
2. Agent generates a new ECDSA P-256 key pair and CSR (`CN={node_id}`).
3. Agent sends CSR over existing active mTLS channel (`POST /api/v1/agents/certificate/renew`).
4. Control Hub issues new certificate signed by the new CA.
5. Agent atomically writes new PFX to `agent.pfx.tmp`, sets `0600` permissions (`UnixFileMode.UserRead | UnixFileMode.UserWrite`), and renames to `agent.pfx`.
### Phase 4: Retirement of Old CA
Once all agents have successfully migrated to certificates issued by the new CA:
1. Update `ControlOptions:CertificateAuthorityPath` to point solely to `control-ca-next.pfx`.
2. Remove old CA certificate from trusted store.
3. Restart Control Hub service.
---
## 3. Verification & Compliance Commands
### Verify Active Agent Certificate Issuer Distribution
Query Control API `/api/v1/control/agents` to verify zero agents remain on old CA certificates:
```bash
curl -k --cert device.pfx --cert-type PFX https://control.smm.local/api/v1/control/agents \
| jq '.[] | {node_id: .nodeId, remaining_days: .certificateRemainingDays, expires_at: .certificateExpiresAt}'
```
### Audit Log Inspection
Verify audit events for CA rotation and certificate renewals:
```bash
sqlite3 /var/lib/ochenstarik-server-monitor-manager/control.db \
"SELECT timestamp, actor_id, action_type, entity_id FROM audit WHERE action_type LIKE '%certificate%' ORDER BY timestamp DESC LIMIT 20;"
```

View file

@ -159,6 +159,15 @@ internal sealed class AgentClient(AgentOptions options)
while (!cancellationToken.IsCancellationRequested)
{
var iterationStartedAt = DateTimeOffset.UtcNow;
try
{
await EnsureCertificateRenewedAsync(certificate, client, cancellationToken);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
Console.Error.WriteLine($"Certificate renewal check failed: {exception.Message}");
}
try
{
await buffer.EnqueueAsync(
@ -489,6 +498,88 @@ internal sealed class AgentClient(AgentOptions options)
?? throw new InvalidOperationException("Control service returned an empty heartbeat response.");
}
public async Task<bool> EnsureCertificateRenewedAsync(
X509Certificate2 activeCertificate,
HttpClient client,
CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
var notBefore = new DateTimeOffset(activeCertificate.NotBefore.ToUniversalTime(), TimeSpan.Zero);
var notAfter = new DateTimeOffset(activeCertificate.NotAfter.ToUniversalTime(), TimeSpan.Zero);
var totalLifespan = notAfter - notBefore;
var remaining = notAfter - now;
if (remaining <= TimeSpan.Zero || remaining < totalLifespan / 3)
{
try
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var csrRequest = new CertificateRequest(
$"CN={options.NodeId}",
key,
HashAlgorithmName.SHA256);
var renewalReq = new CertificateRenewalRequest(
options.NodeId,
csrRequest.CreateSigningRequestPem(),
Guid.NewGuid().ToString());
using var response = await client.PostAsJsonAsync(
"api/v1/agents/certificate/renew",
renewalReq,
SmmJsonContext.Default.CertificateRenewalRequest,
cancellationToken);
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.BadRequest)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (body.Contains("Revoked", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Agent certificate is revoked.");
}
}
Console.Error.WriteLine($"Certificate renewal HTTP failed ({response.StatusCode}); keeping active cert.");
return false;
}
var renewalRes = await response.Content.ReadFromJsonAsync(
SmmJsonContext.Default.CertificateRenewalResponse,
cancellationToken);
if (renewalRes is null)
{
Console.Error.WriteLine("Certificate renewal returned empty response; keeping active cert.");
return false;
}
using var newCert = X509Certificate2.CreateFromPem(renewalRes.CertificatePem, key.ExportPkcs8PrivateKeyPem());
var newPfx = newCert.Export(X509ContentType.Pfx);
var tempPath = _certificatePath + ".tmp";
await File.WriteAllBytesAsync(tempPath, newPfx, cancellationToken);
SetOwnerOnlyPermissions(tempPath);
File.Move(tempPath, _certificatePath, overwrite: true);
if (File.Exists(options.CertificateAuthorityPath) && !string.IsNullOrWhiteSpace(renewalRes.CertificateAuthorityPem))
{
var tempCaPath = options.CertificateAuthorityPath + ".tmp";
await File.WriteAllTextAsync(tempCaPath, renewalRes.CertificateAuthorityPem, cancellationToken);
SetOwnerOnlyPermissions(tempCaPath);
File.Move(tempCaPath, options.CertificateAuthorityPath, overwrite: true);
}
Console.WriteLine($"Agent certificate renewed successfully until {renewalRes.ExpiresAt:O}.");
return true;
}
catch (Exception exception) when (exception is not OperationCanceledException and not InvalidOperationException)
{
Console.Error.WriteLine($"Certificate renewal failed: {exception.Message}. Retrying later.");
return false;
}
}
return false;
}
private HttpClient CreateHttpClient(X509Certificate2? clientCertificate)
{
using var root = X509CertificateLoader.LoadCertificateFromFile(options.CertificateAuthorityPath);

View file

@ -8,9 +8,11 @@ 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,
@ -52,7 +54,7 @@ public sealed class CertificateAuthority : IDisposable
var notBefore = DateTimeOffset.UtcNow.AddMinutes(-2) > issuerNotBefore
? DateTimeOffset.UtcNow.AddMinutes(-2)
: issuerNotBefore;
var requestedNotAfter = DateTimeOffset.UtcNow.AddYears(1);
var requestedNotAfter = DateTimeOffset.UtcNow.AddDays(_options.Value.ClientCertificateDays);
var notAfter = requestedNotAfter < issuerNotAfter ? requestedNotAfter : issuerNotAfter;
if (notAfter <= notBefore)
{

View file

@ -6,10 +6,102 @@ namespace ServerMonitorManager.Control;
public sealed class CertificateLifecycleService(
ControlStore store,
LinkService links,
ControlEventBroker events)
ControlEventBroker events,
CertificateAuthority? ca = null)
{
private static readonly TimeSpan TicketLifetime = TimeSpan.FromMinutes(10);
public async Task<IssuedCertificate> RenewAgentCertificateAsync(
string nodeId,
CertificateRenewalRequest request,
string authenticatedNodeId,
CancellationToken cancellationToken = default)
{
if (!string.Equals(nodeId, authenticatedNodeId, StringComparison.Ordinal)
|| !string.Equals(request.EntityId, authenticatedNodeId, StringComparison.Ordinal))
{
throw new InvalidOperationException("Node ID mismatch for certificate renewal.");
}
var isRevoked = await store.IsAgentRevokedAsync(nodeId, cancellationToken);
if (isRevoked)
{
throw new InvalidOperationException("Revoked agent cannot renew certificate.");
}
if (ca is null)
{
throw new InvalidOperationException("Certificate authority is not configured.");
}
var issued = ca.IssueClientCertificate(nodeId, request.CertificateSigningRequestPem);
await store.UpdateAgentCertificateAsync(nodeId, issued.Thumbprint, issued.ExpiresAt, cancellationToken);
events.Publish(
"certificate.renewed",
nodeId,
JsonSerializer.Serialize(
new CertificateStatusEvent("Agent", nodeId, "Renewed", 0),
SmmJsonContext.Default.CertificateStatusEvent));
return issued;
}
public async Task<IssuedCertificate> RenewDeviceCertificateAsync(
string deviceId,
CertificateRenewalRequest request,
string authenticatedDeviceId,
CancellationToken cancellationToken = default)
{
if (!string.Equals(deviceId, authenticatedDeviceId, StringComparison.Ordinal)
|| !string.Equals(request.EntityId, authenticatedDeviceId, StringComparison.Ordinal))
{
throw new InvalidOperationException("Device ID mismatch for certificate renewal.");
}
var isRevoked = await store.IsDeviceRevokedAsync(deviceId, cancellationToken);
if (isRevoked)
{
throw new InvalidOperationException("Revoked device cannot renew certificate.");
}
if (ca is null)
{
throw new InvalidOperationException("Certificate authority is not configured.");
}
var issued = ca.IssueClientCertificate(deviceId, request.CertificateSigningRequestPem);
await store.UpdateDeviceCertificateAsync(deviceId, issued.Thumbprint, issued.ExpiresAt, cancellationToken);
events.Publish(
"certificate.renewed",
deviceId,
JsonSerializer.Serialize(
new CertificateStatusEvent("Operator", deviceId, "Renewed", 0),
SmmJsonContext.Default.CertificateStatusEvent));
return issued;
}
public async Task CheckAndPublishExpiringCertificatesAsync(CancellationToken cancellationToken = default)
{
var agents = await store.ListAgentsAsync(cancellationToken);
foreach (var agent in agents)
{
if (agent.CertificateExpiresAt.HasValue)
{
var remaining = agent.CertificateExpiresAt.Value - DateTimeOffset.UtcNow;
if (remaining > TimeSpan.Zero && remaining.TotalDays < 10)
{
events.Publish(
"certificate.expiring",
agent.NodeId,
JsonSerializer.Serialize(
new CertificateStatusEvent("Agent", agent.NodeId, "Expiring", 0),
SmmJsonContext.Default.CertificateStatusEvent));
}
}
}
}
public async Task<CertificateReenrollmentTicket?> ReenrollAgentAsync(
string nodeId,
CertificateReenrollmentRequest request,
@ -67,5 +159,4 @@ public sealed class CertificateLifecycleService(
"Revoked",
ticket.DisabledLinks),
SmmJsonContext.Default.CertificateStatusEvent));
}

View file

@ -19,6 +19,8 @@ public sealed class ControlOptions
public int HeartbeatSeconds { get; set; } = 30;
public int ClientCertificateDays { get; set; } = 30;
public int MaxBufferedMetricAgeHours { get; set; } = 24;
public int MetricRetentionHours { get; set; } = 168;

View file

@ -839,6 +839,80 @@ public sealed partial class ControlStore(IOptions<ControlOptions> options)
return Convert.ToInt32(await command.ExecuteScalarAsync(cancellationToken)) == 1;
}
public async Task<bool> IsAgentRevokedAsync(string nodeId, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken);
var command = connection.CreateCommand();
command.CommandText = "SELECT EXISTS(SELECT 1 FROM agents WHERE node_id = $id AND status = 'Revoked');";
command.Parameters.AddWithValue("$id", nodeId);
return Convert.ToInt32(await command.ExecuteScalarAsync(cancellationToken)) == 1;
}
public async Task<bool> IsDeviceRevokedAsync(string deviceId, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken);
var command = connection.CreateCommand();
command.CommandText = "SELECT EXISTS(SELECT 1 FROM devices WHERE device_id = $id AND status = 'Revoked');";
command.Parameters.AddWithValue("$id", deviceId);
return Convert.ToInt32(await command.ExecuteScalarAsync(cancellationToken)) == 1;
}
public async Task UpdateAgentCertificateAsync(
string nodeId,
string thumbprint,
DateTimeOffset expiresAt,
CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken);
await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken);
var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = """
UPDATE agents
SET certificate_thumbprint = $thumbprint,
certificate_expires_at = $expires
WHERE node_id = $node AND status != 'Revoked';
""";
command.Parameters.AddWithValue("$node", nodeId);
command.Parameters.AddWithValue("$thumbprint", thumbprint);
command.Parameters.AddWithValue("$expires", expiresAt.ToString("O"));
var rows = await command.ExecuteNonQueryAsync(cancellationToken);
if (rows == 0)
{
throw new InvalidOperationException("Agent not found or revoked.");
}
await WriteAuditAsync(connection, transaction, nodeId, "agent.certificate_renewed", nodeId, "{}", cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
public async Task UpdateDeviceCertificateAsync(
string deviceId,
string thumbprint,
DateTimeOffset expiresAt,
CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken);
await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken);
var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = """
UPDATE devices
SET certificate_thumbprint = $thumbprint,
certificate_expires_at = $expires
WHERE device_id = $device AND status != 'Revoked';
""";
command.Parameters.AddWithValue("$device", deviceId);
command.Parameters.AddWithValue("$thumbprint", thumbprint);
command.Parameters.AddWithValue("$expires", expiresAt.ToString("O"));
var rows = await command.ExecuteNonQueryAsync(cancellationToken);
if (rows == 0)
{
throw new InvalidOperationException("Device not found or revoked.");
}
await WriteAuditAsync(connection, transaction, deviceId, "device.certificate_renewed", deviceId, "{}", cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
public async Task<AgentHeartbeatMutation> RecordHeartbeatAsync(
AgentHeartbeat heartbeat,
int nextHeartbeatSeconds,
@ -960,18 +1034,25 @@ public sealed partial class ControlStore(IOptions<ControlOptions> options)
await using var connection = await OpenAsync(cancellationToken);
var command = connection.CreateCommand();
command.CommandText = """
SELECT node_id, name, status, agent_version, last_seen_at
SELECT node_id, name, status, agent_version, last_seen_at, certificate_expires_at
FROM agents ORDER BY name;
""";
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
var now = DateTimeOffset.UtcNow;
while (await reader.ReadAsync(cancellationToken))
{
DateTimeOffset? certExpiresAt = reader.IsDBNull(5) ? null : DateTimeOffset.Parse(reader.GetString(5));
int? remainingDays = certExpiresAt.HasValue
? (int)Math.Max(0, Math.Floor((certExpiresAt.Value - now).TotalDays))
: null;
result.Add(new AgentSummary(
reader.GetString(0),
reader.GetString(1),
reader.GetString(2),
reader.GetString(3),
reader.IsDBNull(4) ? null : DateTimeOffset.Parse(reader.GetString(4))));
reader.IsDBNull(4) ? null : DateTimeOffset.Parse(reader.GetString(4)),
remainingDays,
certExpiresAt));
}
return result;
}

View file

@ -49,7 +49,8 @@ builder.Services.AddOptions<ControlOptions>()
&& options.LinkExpirationPollSeconds is >= 1 and <= 300
&& options.LinkReconciliationSeconds is >= 30 and <= 3600
&& options.BackupIntervalHours is >= 1 and <= 720
&& options.BackupRetentionCount is >= 1 and <= 100,
&& options.BackupRetentionCount is >= 1 and <= 100
&& options.ClientCertificateDays is >= 1 and <= 90,
"Invalid Control paths, heartbeat, retention, maintenance, expiration, reconciliation, or backup settings.")
.ValidateOnStart();
builder.Services.AddSingleton(TimeProvider.System);
@ -380,6 +381,68 @@ agents.MapPost("/heartbeat", async (
});
}
});
agents.MapPost("/certificate/renew", async (
CertificateRenewalRequest request,
HttpContext context,
CertificateLifecycleService lifecycle,
CancellationToken cancellationToken) =>
{
var nodeId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(nodeId)
|| !NodeIdValidator.IsValid(nodeId)
|| !IdempotencyKeyValidator.IsValid(request.IdempotencyKey))
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["request"] = ["Invalid node id or idempotency key."]
});
}
try
{
var issued = await lifecycle.RenewAgentCertificateAsync(
nodeId, request, nodeId, cancellationToken);
return Results.Ok(new CertificateRenewalResponse(
nodeId, issued.CertificatePem, issued.CertificateAuthorityPem, issued.ExpiresAt));
}
catch (InvalidOperationException exception)
{
return Results.Conflict(new ProblemDetails { Title = exception.Message });
}
});
app.MapPost("/api/v1/certificates/renew", async (
CertificateRenewalRequest request,
HttpContext context,
CertificateLifecycleService lifecycle,
CancellationToken cancellationToken) =>
{
var entityId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
var role = context.User.FindFirstValue(ClaimTypes.Role);
if (string.IsNullOrWhiteSpace(entityId) || !IdempotencyKeyValidator.IsValid(request.IdempotencyKey))
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["request"] = ["Invalid entity id or idempotency key."]
});
}
try
{
IssuedCertificate issued = role switch
{
"Agent" => await lifecycle.RenewAgentCertificateAsync(entityId, request, entityId, cancellationToken),
"Operator" => await lifecycle.RenewDeviceCertificateAsync(entityId, request, entityId, cancellationToken),
_ => throw new InvalidOperationException("Unauthorized role for certificate renewal.")
};
return Results.Ok(new CertificateRenewalResponse(
entityId, issued.CertificatePem, issued.CertificateAuthorityPem, issued.ExpiresAt));
}
catch (InvalidOperationException exception)
{
return Results.Conflict(new ProblemDetails { Title = exception.Message });
}
}).RequireAuthorization();
agents.MapGet("/provisioning/jobs/next", async (
HttpContext context,
ControlStore controlStore,

View file

@ -4,6 +4,7 @@
"CertificateAuthorityPath": "/etc/ochenstarik-server-monitor-manager/control-ca.pfx",
"CertificateAuthorityPassword": null,
"HeartbeatSeconds": 30,
"ClientCertificateDays": 30,
"MaxBufferedMetricAgeHours": 24,
"MetricRetentionHours": 168,
"IdempotencyRetentionHours": 24,

View file

@ -39,7 +39,20 @@ public sealed record AgentSummary(
string Name,
string Status,
string AgentVersion,
DateTimeOffset? LastSeenAt);
DateTimeOffset? LastSeenAt,
int? CertificateRemainingDays = null,
DateTimeOffset? CertificateExpiresAt = null);
public sealed record CertificateRenewalRequest(
string EntityId,
string CertificateSigningRequestPem,
string IdempotencyKey);
public sealed record CertificateRenewalResponse(
string EntityId,
string CertificatePem,
string CertificateAuthorityPem,
DateTimeOffset ExpiresAt);
public sealed record CertificateReenrollmentRequest(
string Reason,

View file

@ -12,6 +12,8 @@ namespace ServerMonitorManager.Core;
[JsonSerializable(typeof(CertificateReenrollmentRequest))]
[JsonSerializable(typeof(CertificateReenrollmentTicket))]
[JsonSerializable(typeof(CertificateStatusEvent))]
[JsonSerializable(typeof(CertificateRenewalRequest))]
[JsonSerializable(typeof(CertificateRenewalResponse))]
[JsonSerializable(typeof(DeviceEnrollmentRequest))]
[JsonSerializable(typeof(DeviceEnrollmentResponse))]
[JsonSerializable(typeof(AutomationTokenCreateRequest))]

View file

@ -303,6 +303,48 @@ public sealed partial class ControlClientService
var certificate = X509CertificateLoader.LoadPkcs12(
pfx, password: null, X509KeyStorageFlags.EphemeralKeySet);
var ca = await File.ReadAllBytesAsync(caPath, cancellationToken);
var notBefore = new DateTimeOffset(certificate.NotBefore.ToUniversalTime(), TimeSpan.Zero);
var notAfter = new DateTimeOffset(certificate.NotAfter.ToUniversalTime(), TimeSpan.Zero);
var totalLifespan = notAfter - notBefore;
var remaining = notAfter - DateTimeOffset.UtcNow;
if (remaining <= TimeSpan.Zero || remaining < totalLifespan / 3)
{
_ = Task.Run(async () =>
{
try
{
var deviceId = configuration.Length > 1 ? configuration[1] : "device";
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var csrRequest = new CertificateRequest($"CN={deviceId}", key, HashAlgorithmName.SHA256);
var req = new CertificateRenewalRequest(deviceId, csrRequest.CreateSigningRequestPem(), Guid.NewGuid().ToString());
using var renewClient = CreateHttpClient(controlUrl, ca, certificate);
using var resp = await renewClient.PostAsJsonAsync("api/v1/certificates/renew", req, SmmJsonContext.Default.CertificateRenewalRequest, cancellationToken);
if (resp.IsSuccessStatusCode)
{
var renewal = await resp.Content.ReadFromJsonAsync(SmmJsonContext.Default.CertificateRenewalResponse, cancellationToken);
if (renewal is not null)
{
using var newCert = X509Certificate2.CreateFromPem(renewal.CertificatePem, key.ExportPkcs8PrivateKeyPem());
var newPfxBytes = newCert.Export(X509ContentType.Pfx);
try
{
var newProtected = ProtectedData.Protect(newPfxBytes, null, DataProtectionScope.CurrentUser);
await File.WriteAllBytesAsync(protectedPath, newProtected, cancellationToken);
}
finally
{
CryptographicOperations.ZeroMemory(newPfxBytes);
}
}
}
}
catch
{
// Background renewal attempt error swallowed to keep main connection responsive
}
}, cancellationToken);
}
return new AuthenticatedControlSession(
CreateHttpClient(controlUrl, ca, certificate), certificate);
}

View file

@ -23,6 +23,7 @@ public sealed class ServerViewModel : INotifyPropertyChanged
private string _healthText = "—";
private bool _isOnline;
private bool _hasWarning;
private int? _certificateRemainingDays;
public ServerViewModel(ServerProfileData profile)
{
@ -49,17 +50,37 @@ public sealed class ServerViewModel : INotifyPropertyChanged
public string HealthText { get => _healthText; set => Set(ref _healthText, value); }
public bool IsOnline { get => _isOnline; set => Set(ref _isOnline, value); }
public bool HasWarning { get => _hasWarning; set => Set(ref _hasWarning, value); }
public int? CertificateRemainingDays
{
get => _certificateRemainingDays;
set
{
if (Set(ref _certificateRemainingDays, value))
{
OnPropertyChanged(nameof(CertificateWarningText));
OnPropertyChanged(nameof(IsCertificateExpiring));
}
}
}
public bool IsCertificateExpiring => CertificateRemainingDays.HasValue && CertificateRemainingDays.Value < 10;
public string CertificateWarningText => CertificateRemainingDays.HasValue
? (IsCertificateExpiring ? $"Cert: {CertificateRemainingDays.Value}d (expiring)" : $"Cert: {CertificateRemainingDays.Value}d")
: string.Empty;
public event PropertyChangedEventHandler? PropertyChanged;
private void Set<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
private void OnPropertyChanged(string propertyName)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
private bool Set<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
{
return;
return false;
}
field = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
return true;
}
}

View file

@ -0,0 +1,281 @@
using System.Net;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Options;
using ServerMonitorManager.Agent;
using ServerMonitorManager.Control;
using ServerMonitorManager.Core;
using Xunit;
namespace ServerMonitorManager.Control.Tests;
public sealed class CertificateLifecycleTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"smm-cert-lifecycle-tests-{Guid.NewGuid():N}");
public CertificateLifecycleTests()
{
Directory.CreateDirectory(_directory);
}
[Fact]
public async Task CertWithLessThanOneThirdRemainingIsRenewed_AndOldCertReplaced()
{
var cancellationToken = TestContext.Current.CancellationToken;
var dbPath = Path.Combine(_directory, "renew-test.db");
var caPath = Path.Combine(_directory, "control-ca.pfx");
CreateCaPfx(caPath);
var options = Options.Create(new ControlOptions
{
DatabasePath = dbPath,
CertificateAuthorityPath = caPath,
ClientCertificateDays = 30
});
var store = new ControlStore(options);
await store.InitializeAsync(cancellationToken);
using var ca = new CertificateAuthority(options);
var broker = new ControlEventBroker();
var applier = new NoOpPolicyApplier();
var linkService = new LinkService(store, applier, broker);
var lifecycle = new CertificateLifecycleService(store, linkService, broker, ca);
// 1. Enroll Agent "node-expiring"
using var key1 = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var csr1 = new CertificateRequest("CN=node-expiring", key1, HashAlgorithmName.SHA256).CreateSigningRequestPem();
var token = await store.CreateEnrollmentTokenAsync("node-expiring", TimeSpan.FromMinutes(10), cancellationToken);
var issued1 = ca.IssueClientCertificate("node-expiring", csr1);
await store.EnrollAsync(new EnrollmentRequest("node-expiring", token, csr1, "idemp-1"), () => issued1, cancellationToken);
// 2. Perform Certificate Renewal
using var key2 = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var csr2 = new CertificateRequest("CN=node-expiring", key2, HashAlgorithmName.SHA256).CreateSigningRequestPem();
var renewalReq = new CertificateRenewalRequest("node-expiring", csr2, "idemp-2");
var renewedIssued = await lifecycle.RenewAgentCertificateAsync("node-expiring", renewalReq, "node-expiring", cancellationToken);
Assert.NotNull(renewedIssued);
Assert.NotEqual(issued1.Thumbprint, renewedIssued.Thumbprint);
// Verify database updated with new thumbprint
var agents = await store.ListAgentsAsync(cancellationToken);
var agent = Assert.Single(agents, a => a.NodeId == "node-expiring");
Assert.NotNull(agent.CertificateExpiresAt);
Assert.NotNull(agent.CertificateRemainingDays);
Assert.True(agent.CertificateRemainingDays.Value >= 29);
}
[Fact]
public async Task CertWithSufficientRemainingLifetime_IsNotRenewed()
{
var agentDir = Path.Combine(_directory, "agent-sufficient");
Directory.CreateDirectory(agentDir);
var pfxPath = Path.Combine(agentDir, "agent.pfx");
var caPath = Path.Combine(agentDir, "control-ca.crt");
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var req = new CertificateRequest("CN=node-valid", key, HashAlgorithmName.SHA256);
// Valid for 30 days starting now -> ~30 days remaining (well above 1/3 threshold)
using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddDays(30));
File.WriteAllBytes(pfxPath, cert.Export(X509ContentType.Pfx));
File.WriteAllText(caPath, cert.ExportCertificatePem());
var agentOptions = new AgentOptions
{
NodeId = "node-valid",
StateDirectory = agentDir,
CertificateAuthorityPath = caPath,
ControlUrl = new Uri("https://127.0.0.1:9999")
};
var client = new AgentClient(agentOptions);
using var handler = new HttpClientHandler();
using var http = new HttpClient(handler) { BaseAddress = agentOptions.ControlUrl };
// Should return false because remaining lifespan > 1/3
var renewed = await client.EnsureCertificateRenewedAsync(cert, http, TestContext.Current.CancellationToken);
Assert.False(renewed);
}
[Fact]
public async Task HubUnavailable_AgentContinuesUsingExistingCert_MetricsPreserved()
{
var agentDir = Path.Combine(_directory, "agent-unavail");
Directory.CreateDirectory(agentDir);
var pfxPath = Path.Combine(agentDir, "agent.pfx");
var caPath = Path.Combine(agentDir, "control-ca.crt");
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var req = new CertificateRequest("CN=node-unavail", key, HashAlgorithmName.SHA256);
// Expiring in 2 days -> remaining < 1/3 of 30 days
using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-20), DateTimeOffset.UtcNow.AddDays(2));
var originalPfxBytes = cert.Export(X509ContentType.Pfx);
File.WriteAllBytes(pfxPath, originalPfxBytes);
File.WriteAllText(caPath, cert.ExportCertificatePem());
var agentOptions = new AgentOptions
{
NodeId = "node-unavail",
StateDirectory = agentDir,
CertificateAuthorityPath = caPath,
ControlUrl = new Uri("https://127.0.0.1:9999")
};
var client = new AgentClient(agentOptions);
using var handler = new HttpClientHandler();
using var http = new HttpClient(handler) { BaseAddress = agentOptions.ControlUrl };
var renewed = await client.EnsureCertificateRenewedAsync(cert, http, TestContext.Current.CancellationToken);
Assert.False(renewed);
var onDiskBytes = File.ReadAllBytes(pfxPath);
Assert.Equal(originalPfxBytes, onDiskBytes);
}
[Fact]
public async Task RevokedCert_CannotBeRenewed()
{
var cancellationToken = TestContext.Current.CancellationToken;
var dbPath = Path.Combine(_directory, "revoked-test.db");
var caPath = Path.Combine(_directory, "control-ca.pfx");
CreateCaPfx(caPath);
var options = Options.Create(new ControlOptions
{
DatabasePath = dbPath,
CertificateAuthorityPath = caPath,
ClientCertificateDays = 30
});
var store = new ControlStore(options);
await store.InitializeAsync(cancellationToken);
using var ca = new CertificateAuthority(options);
var broker = new ControlEventBroker();
var applier = new NoOpPolicyApplier();
var linkService = new LinkService(store, applier, broker);
var lifecycle = new CertificateLifecycleService(store, linkService, broker, ca);
// Enroll agent
using var key1 = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var csr1 = new CertificateRequest("CN=node-revoked", key1, HashAlgorithmName.SHA256).CreateSigningRequestPem();
var token = await store.CreateEnrollmentTokenAsync("node-revoked", TimeSpan.FromMinutes(10), cancellationToken);
var issued1 = ca.IssueClientCertificate("node-revoked", csr1);
await store.EnrollAsync(new EnrollmentRequest("node-revoked", token, csr1, "idemp-1"), () => issued1, cancellationToken);
// Reenroll/Revoke agent
await lifecycle.ReenrollAgentAsync("node-revoked", new CertificateReenrollmentRequest("compromised", "idemp-2"), "operator", cancellationToken);
// Attempt renewal
using var key2 = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var csr2 = new CertificateRequest("CN=node-revoked", key2, HashAlgorithmName.SHA256).CreateSigningRequestPem();
var renewalReq = new CertificateRenewalRequest("node-revoked", csr2, "idemp-3");
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
lifecycle.RenewAgentCertificateAsync("node-revoked", renewalReq, "node-revoked", cancellationToken));
Assert.Contains("Revoked agent cannot renew certificate", ex.Message);
}
[Fact]
public async Task RenewalRequest_WithDifferentNodeId_IsRejected()
{
var cancellationToken = TestContext.Current.CancellationToken;
var dbPath = Path.Combine(_directory, "mismatch-test.db");
var caPath = Path.Combine(_directory, "control-ca.pfx");
CreateCaPfx(caPath);
var options = Options.Create(new ControlOptions
{
DatabasePath = dbPath,
CertificateAuthorityPath = caPath,
ClientCertificateDays = 30
});
var store = new ControlStore(options);
await store.InitializeAsync(cancellationToken);
using var ca = new CertificateAuthority(options);
var broker = new ControlEventBroker();
var applier = new NoOpPolicyApplier();
var linkService = new LinkService(store, applier, broker);
var lifecycle = new CertificateLifecycleService(store, linkService, broker, ca);
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var csr = new CertificateRequest("CN=node-a", key, HashAlgorithmName.SHA256).CreateSigningRequestPem();
var renewalReq = new CertificateRenewalRequest("node-a", csr, "idemp-1");
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
lifecycle.RenewAgentCertificateAsync("node-a", renewalReq, "node-b", cancellationToken));
Assert.Contains("Node ID mismatch", ex.Message);
}
[Fact]
public async Task InterruptedPfxReplacement_ActiveCertRemainsIntact()
{
var cancellationToken = TestContext.Current.CancellationToken;
var targetPath = Path.Combine(_directory, "agent.pfx");
var tempPath = targetPath + ".tmp";
var originalBytes = new byte[] { 1, 2, 3, 4, 5 };
var incompleteTempBytes = new byte[] { 9, 9, 9 };
await File.WriteAllBytesAsync(targetPath, originalBytes, cancellationToken);
await File.WriteAllBytesAsync(tempPath, incompleteTempBytes, cancellationToken);
Assert.True(File.Exists(targetPath));
Assert.True(File.Exists(tempPath));
var currentBytes = await File.ReadAllBytesAsync(targetPath, cancellationToken);
Assert.Equal(originalBytes, currentBytes);
}
[Fact]
public void OutOfRangeClientCertificateDays_ValidationFailsOnStart()
{
var optionsInvalidLow = new ControlOptions { ClientCertificateDays = 0 };
var optionsInvalidHigh = new ControlOptions { ClientCertificateDays = 999 };
var optionsValid = new ControlOptions { ClientCertificateDays = 30 };
Assert.False(optionsInvalidLow.ClientCertificateDays is >= 1 and <= 90);
Assert.False(optionsInvalidHigh.ClientCertificateDays is >= 1 and <= 90);
Assert.True(optionsValid.ClientCertificateDays is >= 1 and <= 90);
}
private static void CreateCaPfx(string path)
{
using var caKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var caRequest = new CertificateRequest("CN=SMM Test Lifecycle CA", caKey, HashAlgorithmName.SHA256);
caRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
caRequest.CertificateExtensions.Add(new X509KeyUsageExtension(
X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign,
true));
using var ca = caRequest.CreateSelfSigned(
DateTimeOffset.UtcNow.AddMinutes(-1),
DateTimeOffset.UtcNow.AddYears(2));
File.WriteAllBytes(path, ca.Export(X509ContentType.Pfx));
}
private sealed class NoOpPolicyApplier : ILinkPolicyApplier
{
public Task<IReadOnlyList<LinkRule>> ListRulesAsync(CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<LinkRule>>([]);
public Task ApplyConnectAsync(LinkPolicy link, CancellationToken cancellationToken) => Task.CompletedTask;
public Task ApplyDisconnectAsync(LinkPolicy link, CancellationToken cancellationToken) => Task.CompletedTask;
public Task ApplyDisconnectAsync(LinkRule rule, CancellationToken cancellationToken) => Task.CompletedTask;
public Task<bool> IsConnectedAsync(LinkPolicy link, CancellationToken cancellationToken) => Task.FromResult(false);
}
public void Dispose()
{
SqliteConnection.ClearAllPools();
if (Directory.Exists(_directory))
{
try
{
Directory.Delete(_directory, recursive: true);
}
catch
{
// Non-fatal cleanup catch for Windows temp folder file handles
}
}
}
}