feat(cert): client certificate lifecycle management and auto-renewal #25
5 changed files with 72 additions and 80 deletions
|
|
@ -1,46 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -410,39 +410,6 @@ agents.MapPost("/certificate/renew", async (
|
|||
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,
|
||||
|
|
@ -606,6 +573,35 @@ agents.MapPost("/provisioning/jobs/{id}/execution-grant", async (
|
|||
});
|
||||
|
||||
var control = app.MapGroup("/api/v1/control").RequireAuthorization("Operator");
|
||||
control.MapPost("/certificates/renew", async (
|
||||
CertificateRenewalRequest request,
|
||||
HttpContext context,
|
||||
CertificateLifecycleService lifecycle,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var deviceId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrWhiteSpace(deviceId)
|
||||
|| !NodeIdValidator.IsValid(deviceId)
|
||||
|| !IdempotencyKeyValidator.IsValid(request.IdempotencyKey))
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["request"] = ["Invalid device id or idempotency key."]
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var issued = await lifecycle.RenewDeviceCertificateAsync(
|
||||
deviceId, request, deviceId, cancellationToken);
|
||||
return Results.Ok(new CertificateRenewalResponse(
|
||||
deviceId, issued.CertificatePem, issued.CertificateAuthorityPem, issued.ExpiresAt));
|
||||
}
|
||||
catch (InvalidOperationException exception)
|
||||
{
|
||||
return Results.Conflict(new ProblemDetails { Title = exception.Message });
|
||||
}
|
||||
});
|
||||
control.MapGet("/agents", async (ControlStore controlStore, CancellationToken cancellationToken) =>
|
||||
Results.Ok((await controlStore.ListAgentsAsync(cancellationToken)).ToArray()));
|
||||
control.MapGet("/provisioning/catalogs/system-base-install/1", () =>
|
||||
|
|
|
|||
|
|
@ -318,7 +318,7 @@ public sealed partial class ControlClientService
|
|||
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);
|
||||
using var resp = await renewClient.PostAsJsonAsync("api/v1/control/certificates/renew", req, SmmJsonContext.Default.CertificateRenewalRequest, cancellationToken);
|
||||
if (resp.IsSuccessStatusCode)
|
||||
{
|
||||
var renewal = await resp.Content.ReadFromJsonAsync(SmmJsonContext.Default.CertificateRenewalResponse, cancellationToken);
|
||||
|
|
|
|||
|
|
@ -240,6 +240,24 @@ public sealed class CertificateLifecycleTests : IDisposable
|
|||
Assert.True(optionsValid.ClientCertificateDays is >= 1 and <= 90);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CertificateDays45_IssuesCertificateWith45DaysValidity()
|
||||
{
|
||||
var caPath = Path.Combine(_directory, "ca45.pfx");
|
||||
CreateCaPfx(caPath);
|
||||
var options = new ControlOptions { ClientCertificateDays = 45, CertificateAuthorityPath = caPath };
|
||||
using var ca = new CertificateAuthority(Microsoft.Extensions.Options.Options.Create(options));
|
||||
|
||||
using var clientKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var csr = new CertificateRequest("CN=test-node-45", clientKey, HashAlgorithmName.SHA256).CreateSigningRequestPem();
|
||||
|
||||
var issued = ca.IssueClientCertificate("test-node-45", csr);
|
||||
using var issuedCert = X509Certificate2.CreateFromPem(issued.CertificatePem);
|
||||
|
||||
var validitySpan = issuedCert.NotAfter - issuedCert.NotBefore;
|
||||
Assert.InRange(validitySpan.TotalDays, 44.9, 45.1);
|
||||
}
|
||||
|
||||
private static void CreateCaPfx(string path)
|
||||
{
|
||||
using var caKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
|
|||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ServerMonitorManager.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace ServerMonitorManager.Control.Tests;
|
||||
|
|
@ -58,6 +59,29 @@ public sealed class ControlApiTests : IAsyncDisposable
|
|||
"/api/v1/automation/links", TestContext.Current.CancellationToken)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenewalEndpointsRejectUnauthenticatedRequests()
|
||||
{
|
||||
using var anonymous = _factory.CreateClient();
|
||||
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var csr = new CertificateRequest("CN=node-a", key, HashAlgorithmName.SHA256).CreateSigningRequestPem();
|
||||
var req = new CertificateRenewalRequest("node-a", csr, Guid.NewGuid().ToString());
|
||||
|
||||
var agentRenewRes = await anonymous.PostAsJsonAsync(
|
||||
"/api/v1/agents/certificate/renew",
|
||||
req,
|
||||
SmmJsonContext.Default.CertificateRenewalRequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, agentRenewRes.StatusCode);
|
||||
|
||||
var operatorRenewRes = await anonymous.PostAsJsonAsync(
|
||||
"/api/v1/control/certificates/renew",
|
||||
req,
|
||||
SmmJsonContext.Default.CertificateRenewalRequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, operatorRenewRes.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidEnrollmentUsesProblemDetailsResponse()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue