feat: client certificate lifecycle management, auto-renewal, and CA rotation guide
This commit is contained in:
parent
df4dfd177a
commit
25a93f7b35
14 changed files with 832 additions and 9 deletions
46
TEST_EVIDENCE.md
Normal file
46
TEST_EVIDENCE.md
Normal 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.
|
||||
87
docs/certificate-rotation.md
Normal file
87
docs/certificate-rotation.md
Normal 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;"
|
||||
```
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue