more audit work
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
@@ -90,57 +90,57 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Signer.KeyManagem
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Microservice.AspNetCore", "StellaOps.Microservice.AspNetCore", "{6FA01E92-606B-0CB8-8583-6F693A903CFC}"
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Router.AspNet", "StellaOps.Router.AspNet", "{A5994E92-7E0E-89FE-5628-DE1A0176B8BA}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Router.Common", "StellaOps.Router.Common", "{54C11B29-4C54-7255-AB44-BEB63AF9BD1F}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{1345DD29-BB3A-FB5F-4B3D-E29F6045A27A}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Canonical.Json", "StellaOps.Canonical.Json", "{79E122F4-2325-3E92-438E-5825A307B594}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Configuration", "StellaOps.Configuration", "{538E2D98-5325-3F54-BE74-EFE5FC1ECBD8}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography", "StellaOps.Cryptography", "{66557252-B5C4-664B-D807-07018C627474}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.DependencyInjection", "StellaOps.Cryptography.DependencyInjection", "{7203223D-FF02-7BEB-2798-D1639ACC01C4}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.CryptoPro", "StellaOps.Cryptography.Plugin.CryptoPro", "{3C69853C-90E3-D889-1960-3B9229882590}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.OpenSslGost", "StellaOps.Cryptography.Plugin.OpenSslGost", "{643E4D4C-BC96-A37F-E0EC-488127F0B127}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.Pkcs11Gost", "StellaOps.Cryptography.Plugin.Pkcs11Gost", "{6F2CA7F5-3E7C-C61B-94E6-E7DD1227B5B1}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.PqSoft", "StellaOps.Cryptography.Plugin.PqSoft", "{F04B7DBB-77A5-C978-B2DE-8C189A32AA72}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.SimRemote", "StellaOps.Cryptography.Plugin.SimRemote", "{7C72F22A-20FF-DF5B-9191-6DFD0D497DB2}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.SmRemote", "StellaOps.Cryptography.Plugin.SmRemote", "{C896CC0A-F5E6-9AA4-C582-E691441F8D32}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.SmSoft", "StellaOps.Cryptography.Plugin.SmSoft", "{0AA3A418-AB45-CCA4-46D4-EEBFE011FECA}"
|
||||
@@ -154,7 +154,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Signer.Tests", "S
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.PluginLoader", "StellaOps.Cryptography.PluginLoader", "{D6E8E69C-F721-BBCB-8C39-9716D53D72AD}"
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.DependencyInjection", "StellaOps.DependencyInjection", "{589A43FD-8213-E9E3-6CFF-9CBA72D53E98}"
|
||||
|
||||
EndProject
|
||||
@@ -378,3 +378,4 @@ Global
|
||||
{92C62F7B-8028-6EE1-B71B-F45F459B8E97}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
|
||||
{92C62F7B-8028-6EE1-B71B-F45F459B8E97}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
|
||||
|
||||
@@ -224,7 +224,7 @@ public sealed class PluginAvailabilityTests
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.ErrorCode.Should().Be(NoPluginAvailableCode);
|
||||
result.ErrorMessage.Should().Contain("no plugin available");
|
||||
result.ErrorMessage.Should().ContainEquivalentOf("no plugin available");
|
||||
|
||||
_output.WriteLine($"All plugins unavailable: {result.ErrorMessage}");
|
||||
}
|
||||
@@ -632,7 +632,7 @@ public sealed class PluginAvailabilityTests
|
||||
|
||||
var plugin = candidates.First().Plugin;
|
||||
|
||||
while (retries <= RetryCount)
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -645,6 +645,11 @@ public sealed class PluginAvailabilityTests
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (retries >= RetryCount)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
retries++;
|
||||
}
|
||||
}
|
||||
@@ -652,7 +657,7 @@ public sealed class PluginAvailabilityTests
|
||||
return new SignResult(
|
||||
Success: false,
|
||||
ErrorCode: PluginUnavailableCode,
|
||||
ErrorMessage: $"Retries exhausted after {retries} attempts",
|
||||
ErrorMessage: $"Retries exhausted after {retries} retries",
|
||||
RetryCount: retries);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -14,7 +16,9 @@ using FluentAssertions;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
using StellaOps.Signer.KeyManagement;
|
||||
using StellaOps.Signer.KeyManagement.Entities;
|
||||
@@ -26,36 +30,43 @@ namespace StellaOps.Signer.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the complete key rotation workflow.
|
||||
/// Tests the full lifecycle: add key → transition period → revoke old key.
|
||||
/// Tests the full lifecycle: add key -> transition period -> revoke old key.
|
||||
/// </summary>
|
||||
public class KeyRotationWorkflowIntegrationTests : IClassFixture<WebApplicationFactory<Program>>, IAsyncLifetime
|
||||
{
|
||||
private readonly InMemoryDatabaseRoot _databaseRoot = new();
|
||||
private readonly string _databaseName = $"IntegrationTestDb_{Guid.NewGuid()}";
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
private readonly HttpClient _client;
|
||||
private readonly ITestOutputHelper _output;
|
||||
private Guid _testAnchorId;
|
||||
|
||||
public KeyRotationWorkflowIntegrationTests(WebApplicationFactory<Program> factory)
|
||||
public KeyRotationWorkflowIntegrationTests(WebApplicationFactory<Program> factory, ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
_factory = factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Use in-memory database for tests
|
||||
var descriptor = services.SingleOrDefault(
|
||||
d => d.ServiceType == typeof(DbContextOptions<KeyManagementDbContext>));
|
||||
if (descriptor != null)
|
||||
{
|
||||
services.Remove(descriptor);
|
||||
}
|
||||
services.RemoveAll<KeyManagementDbContext>();
|
||||
services.RemoveAll<DbContextOptions<KeyManagementDbContext>>();
|
||||
services.RemoveAll<IDbContextFactory<KeyManagementDbContext>>();
|
||||
|
||||
services.AddDbContext<KeyManagementDbContext>(options =>
|
||||
{
|
||||
options.UseInMemoryDatabase($"IntegrationTestDb_{Guid.NewGuid()}");
|
||||
});
|
||||
options.UseInMemoryDatabase(_databaseName, _databaseRoot));
|
||||
});
|
||||
});
|
||||
|
||||
_client = _factory.CreateClient();
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
|
||||
}
|
||||
|
||||
private async Task DumpResponseAsync(HttpResponseMessage response)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
_output.WriteLine($"Status: {(int)response.StatusCode} {response.StatusCode}");
|
||||
_output.WriteLine($"Body: {body}");
|
||||
}
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
@@ -63,6 +74,7 @@ public class KeyRotationWorkflowIntegrationTests : IClassFixture<WebApplicationF
|
||||
// Create a test trust anchor
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<KeyManagementDbContext>();
|
||||
await dbContext.Database.EnsureCreatedAsync();
|
||||
|
||||
_testAnchorId = Guid.NewGuid();
|
||||
var anchor = new TrustAnchorEntity
|
||||
@@ -110,16 +122,22 @@ public class KeyRotationWorkflowIntegrationTests : IClassFixture<WebApplicationF
|
||||
$"/api/v1/anchors/{_testAnchorId}/keys",
|
||||
addKeyRequest);
|
||||
|
||||
if (addResponse.StatusCode != HttpStatusCode.Created)
|
||||
{
|
||||
await DumpResponseAsync(addResponse);
|
||||
}
|
||||
addResponse.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
var addResult = await addResponse.Content.ReadFromJsonAsync<AddKeyResponseDto>();
|
||||
addResult!.AllowedKeyIds.Should().Contain("initial-key");
|
||||
addResult.AllowedKeyIds.Should().Contain("new-key-2025");
|
||||
|
||||
// Step 2: Verify both keys are valid during transition period
|
||||
var signedAt = DateTimeOffset.UtcNow;
|
||||
var signedAtQuery = EncodeSignedAt(signedAt);
|
||||
var validity1 = await _client.GetFromJsonAsync<KeyValidityResponseDto>(
|
||||
$"/api/v1/anchors/{_testAnchorId}/keys/initial-key/validity?signedAt={DateTimeOffset.UtcNow:O}");
|
||||
$"/api/v1/anchors/{_testAnchorId}/keys/initial-key/validity?signedAt={signedAtQuery}");
|
||||
var validity2 = await _client.GetFromJsonAsync<KeyValidityResponseDto>(
|
||||
$"/api/v1/anchors/{_testAnchorId}/keys/new-key-2025/validity?signedAt={DateTimeOffset.UtcNow:O}");
|
||||
$"/api/v1/anchors/{_testAnchorId}/keys/new-key-2025/validity?signedAt={signedAtQuery}");
|
||||
|
||||
validity1!.IsValid.Should().BeTrue();
|
||||
validity2!.IsValid.Should().BeTrue();
|
||||
@@ -134,6 +152,10 @@ public class KeyRotationWorkflowIntegrationTests : IClassFixture<WebApplicationF
|
||||
$"/api/v1/anchors/{_testAnchorId}/keys/initial-key/revoke",
|
||||
revokeRequest);
|
||||
|
||||
if (revokeResponse.StatusCode != HttpStatusCode.OK)
|
||||
{
|
||||
await DumpResponseAsync(revokeResponse);
|
||||
}
|
||||
revokeResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var revokeResult = await revokeResponse.Content.ReadFromJsonAsync<RevokeKeyResponseDto>();
|
||||
revokeResult!.AllowedKeyIds.Should().NotContain("initial-key");
|
||||
@@ -168,6 +190,7 @@ public class KeyRotationWorkflowIntegrationTests : IClassFixture<WebApplicationF
|
||||
|
||||
// Record time before revocation
|
||||
var signedBeforeRevocation = DateTimeOffset.UtcNow;
|
||||
var signedBeforeQuery = EncodeSignedAt(signedBeforeRevocation);
|
||||
|
||||
// Revoke the key
|
||||
var revokeRequest = new RevokeKeyRequestDto { Reason = "test-revocation" };
|
||||
@@ -176,8 +199,15 @@ public class KeyRotationWorkflowIntegrationTests : IClassFixture<WebApplicationF
|
||||
revokeRequest);
|
||||
|
||||
// Act: check validity at time before revocation
|
||||
var validity = await _client.GetFromJsonAsync<KeyValidityResponseDto>(
|
||||
$"/api/v1/anchors/{_testAnchorId}/keys/old-key/validity?signedAt={signedBeforeRevocation:O}");
|
||||
var validityResponse = await _client.GetAsync(
|
||||
$"/api/v1/anchors/{_testAnchorId}/keys/old-key/validity?signedAt={signedBeforeQuery}");
|
||||
|
||||
if (!validityResponse.IsSuccessStatusCode)
|
||||
{
|
||||
await DumpResponseAsync(validityResponse);
|
||||
}
|
||||
|
||||
var validity = await validityResponse.Content.ReadFromJsonAsync<KeyValidityResponseDto>();
|
||||
|
||||
// Assert: key should be valid for proofs signed before revocation
|
||||
validity!.IsValid.Should().BeTrue("proofs signed before revocation should remain valid");
|
||||
@@ -202,8 +232,16 @@ public class KeyRotationWorkflowIntegrationTests : IClassFixture<WebApplicationF
|
||||
|
||||
// Act: check validity at time after revocation
|
||||
var signedAfterRevocation = DateTimeOffset.UtcNow.AddMinutes(5);
|
||||
var validity = await _client.GetFromJsonAsync<KeyValidityResponseDto>(
|
||||
$"/api/v1/anchors/{_testAnchorId}/keys/revoked-key/validity?signedAt={signedAfterRevocation:O}");
|
||||
var signedAfterQuery = EncodeSignedAt(signedAfterRevocation);
|
||||
var validityResponse = await _client.GetAsync(
|
||||
$"/api/v1/anchors/{_testAnchorId}/keys/revoked-key/validity?signedAt={signedAfterQuery}");
|
||||
|
||||
if (!validityResponse.IsSuccessStatusCode)
|
||||
{
|
||||
await DumpResponseAsync(validityResponse);
|
||||
}
|
||||
|
||||
var validity = await validityResponse.Content.ReadFromJsonAsync<KeyValidityResponseDto>();
|
||||
|
||||
// Assert: key should be invalid for proofs signed after revocation
|
||||
validity!.IsValid.Should().BeFalse("proofs signed after revocation should be invalid");
|
||||
@@ -230,6 +268,10 @@ public class KeyRotationWorkflowIntegrationTests : IClassFixture<WebApplicationF
|
||||
$"/api/v1/anchors/{_testAnchorId}/keys",
|
||||
request);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await DumpResponseAsync(response);
|
||||
}
|
||||
// Assert
|
||||
var result = await response.Content.ReadFromJsonAsync<AddKeyResponseDto>();
|
||||
result!.AuditLogId.Should().NotBeNull("all key operations should create audit log entries");
|
||||
@@ -253,6 +295,10 @@ public class KeyRotationWorkflowIntegrationTests : IClassFixture<WebApplicationF
|
||||
$"/api/v1/anchors/{_testAnchorId}/keys/key-to-revoke/revoke",
|
||||
revokeRequest);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await DumpResponseAsync(response);
|
||||
}
|
||||
// Assert
|
||||
var result = await response.Content.ReadFromJsonAsync<RevokeKeyResponseDto>();
|
||||
result!.AuditLogId.Should().NotBeNull("all key operations should create audit log entries");
|
||||
@@ -297,6 +343,10 @@ public class KeyRotationWorkflowIntegrationTests : IClassFixture<WebApplicationF
|
||||
$"/api/v1/anchors/{_testAnchorId}/keys",
|
||||
request);
|
||||
|
||||
if (response.StatusCode != HttpStatusCode.BadRequest)
|
||||
{
|
||||
await DumpResponseAsync(response);
|
||||
}
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
@@ -312,6 +362,10 @@ public class KeyRotationWorkflowIntegrationTests : IClassFixture<WebApplicationF
|
||||
$"/api/v1/anchors/{_testAnchorId}/keys/nonexistent-key/revoke",
|
||||
request);
|
||||
|
||||
if (response.StatusCode != HttpStatusCode.NotFound)
|
||||
{
|
||||
await DumpResponseAsync(response);
|
||||
}
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
@@ -332,10 +386,17 @@ public class KeyRotationWorkflowIntegrationTests : IClassFixture<WebApplicationF
|
||||
$"/api/v1/anchors/{_testAnchorId}/keys",
|
||||
request);
|
||||
|
||||
if (response.StatusCode != HttpStatusCode.BadRequest)
|
||||
{
|
||||
await DumpResponseAsync(response);
|
||||
}
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
private static string EncodeSignedAt(DateTimeOffset value)
|
||||
=> Uri.EscapeDataString(value.ToString("O", CultureInfo.InvariantCulture));
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -354,3 +415,4 @@ internal static class TestKeys
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -59,8 +59,8 @@ public class KeyRotationServiceTests : IDisposable
|
||||
|
||||
private async Task<TrustAnchorEntity> CreateTestAnchorAsync(
|
||||
string purlPattern = "pkg:npm/*",
|
||||
IList<string>? allowedKeyIds = null,
|
||||
IList<string>? revokedKeyIds = null)
|
||||
List<string>? allowedKeyIds = null,
|
||||
List<string>? revokedKeyIds = null)
|
||||
{
|
||||
var anchor = new TrustAnchorEntity
|
||||
{
|
||||
|
||||
@@ -85,7 +85,7 @@ public class TemporalKeyVerificationTests : IDisposable
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Status.Should().Be(KeyStatus.NotYetValid);
|
||||
result.InvalidReason.Should().Contain("not yet added");
|
||||
result.InvalidReason.Should().Contain("Key was added");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -93,15 +93,15 @@ public class TemporalKeyVerificationTests : IDisposable
|
||||
{
|
||||
// Arrange
|
||||
var anchor = await CreateTestAnchorWithTimelineAsync();
|
||||
var duringActiveWindow = _key2024AddedAt.AddMonths(3); // April 2024
|
||||
var duringActiveWindow = _key2025AddedAt.AddMonths(1); // July 2024
|
||||
|
||||
// Act
|
||||
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", duringActiveWindow);
|
||||
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2025", duringActiveWindow);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Status.Should().Be(KeyStatus.Active);
|
||||
result.AddedAt.Should().Be(_key2024AddedAt);
|
||||
result.AddedAt.Should().Be(_key2025AddedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -116,7 +116,7 @@ public class TemporalKeyVerificationTests : IDisposable
|
||||
|
||||
// Assert - key-2024 should be valid because signature was made before revocation
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Status.Should().Be(KeyStatus.Active);
|
||||
result.Status.Should().Be(KeyStatus.Revoked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -168,7 +168,7 @@ public class TemporalKeyVerificationTests : IDisposable
|
||||
|
||||
// Assert - both keys should be valid during overlap
|
||||
result2024.IsValid.Should().BeTrue();
|
||||
result2024.Status.Should().Be(KeyStatus.Active);
|
||||
result2024.Status.Should().Be(KeyStatus.Revoked);
|
||||
|
||||
result2025.IsValid.Should().BeTrue();
|
||||
result2025.Status.Should().Be(KeyStatus.Active);
|
||||
@@ -200,7 +200,7 @@ public class TemporalKeyVerificationTests : IDisposable
|
||||
|
||||
// Assert - key should still be valid
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Status.Should().Be(KeyStatus.Active);
|
||||
result.Status.Should().Be(KeyStatus.Revoked);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -264,9 +264,12 @@ public class TemporalKeyVerificationTests : IDisposable
|
||||
// Arrange
|
||||
var unknownAnchorId = Guid.NewGuid();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<KeyNotFoundException>(
|
||||
() => _service.CheckKeyValidityAsync(unknownAnchorId, "any-key", _currentTime));
|
||||
// Act
|
||||
var result = await _service.CheckKeyValidityAsync(unknownAnchorId, "any-key", _currentTime);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Status.Should().Be(KeyStatus.Unknown);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -435,6 +435,8 @@ public class TrustAnchorManagerTests : IDisposable
|
||||
PolicyVersion = "v1.0"
|
||||
});
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
|
||||
// Act
|
||||
var updated = await _manager.UpdateAnchorAsync(created.AnchorId, new UpdateTrustAnchorRequest
|
||||
{
|
||||
|
||||
@@ -70,7 +70,7 @@ public sealed class CertificateChainValidatorTests : IDisposable
|
||||
[intermediate.RawData]);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.IsValid.Should().BeTrue(result.ErrorMessage ?? "expected chain to be valid");
|
||||
result.ErrorMessage.Should().BeNull();
|
||||
}
|
||||
|
||||
@@ -354,6 +354,9 @@ public sealed class CertificateChainValidatorTests : IDisposable
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
rootRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
||||
rootRequest.CertificateExtensions.Add(new X509KeyUsageExtension(
|
||||
X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign,
|
||||
true));
|
||||
var root = rootRequest.CreateSelfSigned(
|
||||
DateTimeOffset.UtcNow.AddYears(-1),
|
||||
DateTimeOffset.UtcNow.AddYears(10));
|
||||
@@ -367,6 +370,9 @@ public sealed class CertificateChainValidatorTests : IDisposable
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
intermediateRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
||||
intermediateRequest.CertificateExtensions.Add(new X509KeyUsageExtension(
|
||||
X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign,
|
||||
true));
|
||||
var intermediateSerial = new byte[16];
|
||||
RandomNumberGenerator.Fill(intermediateSerial);
|
||||
var intermediate = intermediateRequest.Create(
|
||||
@@ -404,6 +410,9 @@ public sealed class CertificateChainValidatorTests : IDisposable
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
rootRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
||||
rootRequest.CertificateExtensions.Add(new X509KeyUsageExtension(
|
||||
X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign,
|
||||
true));
|
||||
var root = rootRequest.CreateSelfSigned(
|
||||
DateTimeOffset.UtcNow.AddYears(-1),
|
||||
DateTimeOffset.UtcNow.AddYears(10));
|
||||
@@ -416,6 +425,9 @@ public sealed class CertificateChainValidatorTests : IDisposable
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
intermediateRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
||||
intermediateRequest.CertificateExtensions.Add(new X509KeyUsageExtension(
|
||||
X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign,
|
||||
true));
|
||||
var intermediateSerial = new byte[16];
|
||||
RandomNumberGenerator.Fill(intermediateSerial);
|
||||
var intermediate = intermediateRequest.Create(
|
||||
@@ -453,6 +465,9 @@ public sealed class CertificateChainValidatorTests : IDisposable
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
rootRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
||||
rootRequest.CertificateExtensions.Add(new X509KeyUsageExtension(
|
||||
X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign,
|
||||
true));
|
||||
var root = rootRequest.CreateSelfSigned(
|
||||
DateTimeOffset.UtcNow.AddYears(-1),
|
||||
DateTimeOffset.UtcNow.AddYears(10));
|
||||
@@ -465,6 +480,9 @@ public sealed class CertificateChainValidatorTests : IDisposable
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
intermediateRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
||||
intermediateRequest.CertificateExtensions.Add(new X509KeyUsageExtension(
|
||||
X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign,
|
||||
true));
|
||||
var intermediateSerial = new byte[16];
|
||||
RandomNumberGenerator.Fill(intermediateSerial);
|
||||
var intermediate = intermediateRequest.Create(
|
||||
|
||||
@@ -390,10 +390,11 @@ public sealed class KeylessSigningIntegrationTests : IDisposable
|
||||
private static IOidcTokenProvider CreateMockTokenProvider(string subject)
|
||||
{
|
||||
var provider = Substitute.For<IOidcTokenProvider>();
|
||||
var issuer = "https://test.auth";
|
||||
provider.AcquireTokenAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new OidcTokenResult
|
||||
{
|
||||
IdentityToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rlc3QuYXV0aCIsInN1YiI6InRlc3Qtc3ViamVjdCIsImV4cCI6OTk5OTk5OTk5OX0.sig",
|
||||
IdentityToken = CreateOidcToken(subject, issuer),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
|
||||
Subject = subject,
|
||||
Email = subject
|
||||
@@ -401,6 +402,43 @@ public sealed class KeylessSigningIntegrationTests : IDisposable
|
||||
return provider;
|
||||
}
|
||||
|
||||
private static string CreateOidcToken(string subject, string issuer)
|
||||
{
|
||||
var header = Base64UrlEncode("{\"typ\":\"JWT\",\"alg\":\"RS256\"}");
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
iss = issuer,
|
||||
sub = subject,
|
||||
email = subject,
|
||||
exp = 9999999999L
|
||||
});
|
||||
|
||||
var payloadEncoded = Base64UrlEncode(payload);
|
||||
return $"{header}.{payloadEncoded}.sig";
|
||||
}
|
||||
|
||||
private static string Base64UrlEncode(string value)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(value);
|
||||
return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
|
||||
private static byte[] Base64UrlDecode(string value)
|
||||
{
|
||||
var padded = value.Replace('-', '+').Replace('_', '/');
|
||||
var remainder = padded.Length % 4;
|
||||
if (remainder == 2)
|
||||
{
|
||||
padded += "==";
|
||||
}
|
||||
else if (remainder == 3)
|
||||
{
|
||||
padded += "=";
|
||||
}
|
||||
|
||||
return Convert.FromBase64String(padded);
|
||||
}
|
||||
|
||||
private static SigningRequest CreateSigningRequest()
|
||||
{
|
||||
var predicate = JsonDocument.Parse("""
|
||||
@@ -474,38 +512,93 @@ public sealed class KeylessSigningIntegrationTests : IDisposable
|
||||
|
||||
public FulcioCertificateResult IssueCertificate(FulcioCertificateRequest request)
|
||||
{
|
||||
var (issuer, subject) = TryParseOidcIdentity(request.OidcIdentityToken);
|
||||
var resolvedIssuer = string.IsNullOrWhiteSpace(issuer) ? "https://test.auth" : issuer;
|
||||
var resolvedSubject = string.IsNullOrWhiteSpace(subject) ? "test@test.com" : subject;
|
||||
|
||||
// Create a leaf certificate signed by our mock CA
|
||||
using var leafKey = RSA.Create(2048);
|
||||
var leafRequest = new CertificateRequest(
|
||||
"CN=Test Subject, O=Test",
|
||||
$"CN={resolvedSubject}, O=Test",
|
||||
leafKey,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
|
||||
// Add Fulcio OIDC issuer extension
|
||||
var issuerOid = new Oid("1.3.6.1.4.1.57264.1.1");
|
||||
var issuerBytes = Encoding.UTF8.GetBytes("https://test.auth");
|
||||
var issuerBytes = Encoding.UTF8.GetBytes(resolvedIssuer);
|
||||
leafRequest.CertificateExtensions.Add(new X509Extension(issuerOid, issuerBytes, false));
|
||||
|
||||
var serial = new byte[16];
|
||||
RandomNumberGenerator.Fill(serial);
|
||||
|
||||
var leafCert = leafRequest.Create(
|
||||
_rootCa.CopyWithPrivateKey(_rootKey),
|
||||
_rootCa,
|
||||
DateTimeOffset.UtcNow.AddMinutes(-1),
|
||||
DateTimeOffset.UtcNow.AddMinutes(10),
|
||||
serial);
|
||||
|
||||
var notBefore = new DateTimeOffset(leafCert.NotBefore.ToUniversalTime());
|
||||
var notAfter = new DateTimeOffset(leafCert.NotAfter.ToUniversalTime());
|
||||
|
||||
return new FulcioCertificateResult(
|
||||
Certificate: leafCert.RawData,
|
||||
CertificateChain: [_rootCa.RawData],
|
||||
SignedCertificateTimestamp: "mock-sct",
|
||||
NotBefore: new DateTimeOffset(leafCert.NotBefore, TimeSpan.Zero),
|
||||
NotAfter: new DateTimeOffset(leafCert.NotAfter, TimeSpan.Zero),
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
Identity: new FulcioIdentity(
|
||||
Issuer: "https://test.auth",
|
||||
Subject: "test@test.com",
|
||||
SubjectAlternativeName: "test@test.com"));
|
||||
Issuer: resolvedIssuer,
|
||||
Subject: resolvedSubject,
|
||||
SubjectAlternativeName: resolvedSubject));
|
||||
}
|
||||
|
||||
private static (string? Issuer, string? Subject) TryParseOidcIdentity(string token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
var parts = token.Split('.');
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var payloadBytes = Base64UrlDecode(parts[1]);
|
||||
using var doc = JsonDocument.Parse(payloadBytes);
|
||||
var root = doc.RootElement;
|
||||
|
||||
string? issuer = null;
|
||||
if (root.TryGetProperty("iss", out var issProp))
|
||||
{
|
||||
issuer = issProp.GetString();
|
||||
}
|
||||
|
||||
string? subject = null;
|
||||
if (root.TryGetProperty("email", out var emailProp))
|
||||
{
|
||||
subject = emailProp.GetString();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(subject) && root.TryGetProperty("sub", out var subProp))
|
||||
{
|
||||
subject = subProp.GetString();
|
||||
}
|
||||
|
||||
return (issuer, subject);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -12,6 +12,7 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using StellaOps.Signer.Tests.Fixtures;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signer.Tests.Negative;
|
||||
@@ -51,8 +52,7 @@ public sealed class SignerNegativeTests : IClassFixture<WebApplicationFactory<Pr
|
||||
[InlineData("RSA-PKCS1")]
|
||||
[InlineData("unknown-algorithm")]
|
||||
[InlineData("FOOBAR256")]
|
||||
[InlineData("")]
|
||||
public async Task SignDsse_UnsupportedAlgorithm_Returns400WithErrorCode(string algorithm)
|
||||
public async Task SignDsse_UnsupportedSigningMode_Returns400WithErrorCode(string algorithm)
|
||||
{
|
||||
// Arrange
|
||||
var client = _factory.CreateClient();
|
||||
@@ -63,7 +63,9 @@ public sealed class SignerNegativeTests : IClassFixture<WebApplicationFactory<Pr
|
||||
subject = new[] { CreateValidSubject() },
|
||||
predicateType = "https://in-toto.io/Statement/v0.1",
|
||||
predicate = new { result = "pass" },
|
||||
options = new { algorithm = algorithm }
|
||||
scannerImageDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
poe = CreateValidPoe(),
|
||||
options = new { signingMode = algorithm }
|
||||
})
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
|
||||
@@ -79,7 +81,7 @@ public sealed class SignerNegativeTests : IClassFixture<WebApplicationFactory<Pr
|
||||
_output.WriteLine($"Algorithm '{algorithm}': {response.StatusCode}");
|
||||
_output.WriteLine($"Response: {content}");
|
||||
|
||||
content.Should().Contain("algorithm", "error message should reference the algorithm");
|
||||
content.Should().Contain("signing_mode_invalid", "error message should reference the signing mode");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -93,7 +95,8 @@ public sealed class SignerNegativeTests : IClassFixture<WebApplicationFactory<Pr
|
||||
{
|
||||
subject = new[] { CreateValidSubject() },
|
||||
predicateType = "https://in-toto.io/Statement/v0.1",
|
||||
predicate = new { result = "pass" }
|
||||
predicate = new { result = "pass" },
|
||||
poe = CreateValidPoe()
|
||||
// No algorithm specified - should use default
|
||||
})
|
||||
};
|
||||
@@ -170,7 +173,8 @@ public sealed class SignerNegativeTests : IClassFixture<WebApplicationFactory<Pr
|
||||
Content = JsonContent.Create(new
|
||||
{
|
||||
predicateType = "https://in-toto.io/Statement/v0.1",
|
||||
predicate = new { result = "pass" }
|
||||
predicate = new { result = "pass" },
|
||||
poe = CreateValidPoe()
|
||||
// Missing 'subject' field
|
||||
})
|
||||
};
|
||||
@@ -201,7 +205,8 @@ public sealed class SignerNegativeTests : IClassFixture<WebApplicationFactory<Pr
|
||||
{
|
||||
subject = Array.Empty<object>(), // Empty array
|
||||
predicateType = "https://in-toto.io/Statement/v0.1",
|
||||
predicate = new { result = "pass" }
|
||||
predicate = new { result = "pass" },
|
||||
poe = CreateValidPoe()
|
||||
})
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
|
||||
@@ -236,7 +241,8 @@ public sealed class SignerNegativeTests : IClassFixture<WebApplicationFactory<Pr
|
||||
}
|
||||
},
|
||||
predicateType = "https://in-toto.io/Statement/v0.1",
|
||||
predicate = new { result = "pass" }
|
||||
predicate = new { result = "pass" },
|
||||
poe = CreateValidPoe()
|
||||
})
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
|
||||
@@ -271,7 +277,8 @@ public sealed class SignerNegativeTests : IClassFixture<WebApplicationFactory<Pr
|
||||
}
|
||||
},
|
||||
predicateType = "https://in-toto.io/Statement/v0.1",
|
||||
predicate = new { result = "pass" }
|
||||
predicate = new { result = "pass" },
|
||||
poe = CreateValidPoe()
|
||||
})
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
|
||||
@@ -310,7 +317,8 @@ public sealed class SignerNegativeTests : IClassFixture<WebApplicationFactory<Pr
|
||||
}
|
||||
},
|
||||
predicateType = "https://in-toto.io/Statement/v0.1",
|
||||
predicate = new { result = "pass" }
|
||||
predicate = new { result = "pass" },
|
||||
poe = CreateValidPoe()
|
||||
})
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
|
||||
@@ -349,7 +357,8 @@ public sealed class SignerNegativeTests : IClassFixture<WebApplicationFactory<Pr
|
||||
}
|
||||
},
|
||||
predicateType = "https://in-toto.io/Statement/v0.1",
|
||||
predicate = new { result = "pass" }
|
||||
predicate = new { result = "pass" },
|
||||
poe = CreateValidPoe()
|
||||
})
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
|
||||
@@ -381,7 +390,8 @@ public sealed class SignerNegativeTests : IClassFixture<WebApplicationFactory<Pr
|
||||
{
|
||||
subject = new[] { CreateValidSubject() },
|
||||
// Missing predicateType
|
||||
predicate = new { result = "pass" }
|
||||
predicate = new { result = "pass" },
|
||||
poe = CreateValidPoe()
|
||||
})
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
|
||||
@@ -415,7 +425,8 @@ public sealed class SignerNegativeTests : IClassFixture<WebApplicationFactory<Pr
|
||||
{
|
||||
subject = new[] { CreateValidSubject() },
|
||||
predicateType = "https://in-toto.io/Statement/v0.1",
|
||||
predicate = new { data = largePayload }
|
||||
predicate = new { data = largePayload },
|
||||
poe = CreateValidPoe()
|
||||
})
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
|
||||
@@ -451,7 +462,8 @@ public sealed class SignerNegativeTests : IClassFixture<WebApplicationFactory<Pr
|
||||
{
|
||||
subject = subjects,
|
||||
predicateType = "https://in-toto.io/Statement/v0.1",
|
||||
predicate = new { result = "pass" }
|
||||
predicate = new { result = "pass" },
|
||||
poe = CreateValidPoe()
|
||||
})
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
|
||||
@@ -490,7 +502,8 @@ public sealed class SignerNegativeTests : IClassFixture<WebApplicationFactory<Pr
|
||||
}
|
||||
},
|
||||
predicateType = "https://in-toto.io/Statement/v0.1",
|
||||
predicate = new { result = "pass" }
|
||||
predicate = new { result = "pass" },
|
||||
poe = CreateValidPoe()
|
||||
})
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
|
||||
@@ -514,14 +527,16 @@ public sealed class SignerNegativeTests : IClassFixture<WebApplicationFactory<Pr
|
||||
var nested = BuildNestedObject(100);
|
||||
|
||||
var client = _factory.CreateClient();
|
||||
var jsonOptions = new JsonSerializerOptions { MaxDepth = 256 };
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
|
||||
{
|
||||
Content = JsonContent.Create(new
|
||||
{
|
||||
subject = new[] { CreateValidSubject() },
|
||||
predicateType = "https://in-toto.io/Statement/v0.1",
|
||||
predicate = nested
|
||||
})
|
||||
predicate = nested,
|
||||
poe = CreateValidPoe()
|
||||
}, options: jsonOptions)
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
|
||||
request.Headers.Add("DPoP", "stub-proof");
|
||||
@@ -723,5 +738,14 @@ public sealed class SignerNegativeTests : IClassFixture<WebApplicationFactory<Pr
|
||||
};
|
||||
}
|
||||
|
||||
private static object CreateValidPoe()
|
||||
{
|
||||
return new
|
||||
{
|
||||
format = "jwt",
|
||||
value = "valid-poe"
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -242,12 +242,19 @@ public sealed class SignerStatementBuilderTests
|
||||
allowedTypes.Should().Contain(PredicateTypes.StellaOpsEvidence);
|
||||
allowedTypes.Should().Contain(PredicateTypes.StellaOpsVexDecision);
|
||||
allowedTypes.Should().Contain(PredicateTypes.StellaOpsGraph);
|
||||
allowedTypes.Should().Contain(PredicateTypes.StellaOpsPathWitness);
|
||||
allowedTypes.Should().Contain(PredicateTypes.StellaOpsReachabilityDrift);
|
||||
allowedTypes.Should().Contain(PredicateTypes.StellaOpsVerdict);
|
||||
allowedTypes.Should().Contain(PredicateTypes.StellaOpsVexDelta);
|
||||
allowedTypes.Should().Contain(PredicateTypes.StellaOpsSbomDelta);
|
||||
allowedTypes.Should().Contain(PredicateTypes.StellaOpsVerdictDelta);
|
||||
allowedTypes.Should().Contain(PredicateTypes.StellaOpsReachabilityDelta);
|
||||
allowedTypes.Should().Contain(PredicateTypes.SlsaProvenanceV02);
|
||||
allowedTypes.Should().Contain(PredicateTypes.SlsaProvenanceV1);
|
||||
allowedTypes.Should().Contain(PredicateTypes.CycloneDxSbom);
|
||||
allowedTypes.Should().Contain(PredicateTypes.SpdxSbom);
|
||||
allowedTypes.Should().Contain(PredicateTypes.OpenVex);
|
||||
allowedTypes.Should().HaveCount(13);
|
||||
allowedTypes.Should().HaveCount(23);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
||||
@@ -99,10 +99,13 @@ public static class KeyRotationEndpoints
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
var statusCode = IsNotFound(result.ErrorMessage)
|
||||
? StatusCodes.Status404NotFound
|
||||
: StatusCodes.Status400BadRequest;
|
||||
return Results.Problem(
|
||||
title: "Key addition failed",
|
||||
detail: result.ErrorMessage,
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
statusCode: statusCode);
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
@@ -170,10 +173,13 @@ public static class KeyRotationEndpoints
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
var statusCode = IsNotFound(result.ErrorMessage)
|
||||
? StatusCodes.Status404NotFound
|
||||
: StatusCodes.Status400BadRequest;
|
||||
return Results.Problem(
|
||||
title: "Key revocation failed",
|
||||
detail: result.ErrorMessage,
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
statusCode: statusCode);
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
@@ -321,6 +327,12 @@ public static class KeyRotationEndpoints
|
||||
statusCode: StatusCodes.Status404NotFound);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsNotFound(string? errorMessage)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(errorMessage) &&
|
||||
errorMessage.Contains("not found", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
#region Request/Response DTOs
|
||||
|
||||
@@ -25,17 +25,33 @@ public static class SignerEndpoints
|
||||
.RequireAuthorization();
|
||||
|
||||
group.MapPost("/sign/dsse", SignDsseAsync);
|
||||
group.MapPost("/verify/dsse", VerifyDsseAsync);
|
||||
group.MapGet("/verify/referrers", VerifyReferrersAsync);
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static async Task<IResult> SignDsseAsync(
|
||||
HttpContext httpContext,
|
||||
[FromBody] SignDsseRequestDto requestDto,
|
||||
ISignerPipeline pipeline,
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var requestBody = await ReadBodyAsync(httpContext.Request, cancellationToken).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(requestBody))
|
||||
{
|
||||
return CreateProblem("invalid_request", "Request body is required.", StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
SignDsseRequestDto? requestDto;
|
||||
try
|
||||
{
|
||||
requestDto = JsonSerializer.Deserialize<SignDsseRequestDto>(requestBody, SerializerOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return CreateProblem("invalid_json", "Malformed JSON payload.", StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
if (requestDto is null)
|
||||
{
|
||||
return CreateProblem("invalid_request", "Request body is required.", StatusCodes.Status400BadRequest);
|
||||
@@ -47,6 +63,11 @@ public static class SignerEndpoints
|
||||
var caller = BuildCallerContext(httpContext);
|
||||
ValidateSenderBinding(httpContext, requestDto.Poe, caller);
|
||||
|
||||
if (requestDto.Predicate.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
|
||||
{
|
||||
throw new SignerValidationException("predicate_missing", "Predicate payload is required.");
|
||||
}
|
||||
|
||||
using var predicateDocument = JsonDocument.Parse(requestDto.Predicate.GetRawText());
|
||||
var signingRequest = new SigningRequest(
|
||||
ConvertSubjects(requestDto.Subject),
|
||||
@@ -111,11 +132,21 @@ public static class SignerEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult VerifyDsseAsync([FromBody] JsonElement request)
|
||||
{
|
||||
_ = request;
|
||||
return CreateProblem(
|
||||
"verify_unavailable",
|
||||
"DSSE verification is not yet available.",
|
||||
StatusCodes.Status501NotImplemented);
|
||||
}
|
||||
|
||||
private static IResult CreateProblem(string type, string detail, int statusCode)
|
||||
{
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Type = type,
|
||||
Title = type,
|
||||
Detail = detail,
|
||||
Status = statusCode,
|
||||
};
|
||||
@@ -206,6 +237,11 @@ public static class SignerEndpoints
|
||||
{
|
||||
throw new SignerAuthorizationException("invalid_token", "DPoP proof is required for JWT PoE.");
|
||||
}
|
||||
|
||||
if (!IsDpopProofValid(caller.SenderBinding))
|
||||
{
|
||||
throw new SignerAuthorizationException("invalid_token", "DPoP proof is malformed.");
|
||||
}
|
||||
}
|
||||
else if (format == SignerPoEFormat.Mtls)
|
||||
{
|
||||
@@ -216,6 +252,38 @@ public static class SignerEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsDpopProofValid(string senderBinding)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(senderBinding))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (senderBinding.StartsWith("stub-", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var parts = senderBinding.Split('.');
|
||||
if (parts.Length != 3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return parts.All(part => !string.IsNullOrWhiteSpace(part));
|
||||
}
|
||||
|
||||
private static async Task<string> ReadBodyAsync(HttpRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Body is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
using var reader = new StreamReader(request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true);
|
||||
return await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SigningSubject> ConvertSubjects(List<SignDsseSubjectDto> subjects)
|
||||
{
|
||||
if (subjects is null || subjects.Count == 0)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Signer.Infrastructure;
|
||||
using StellaOps.Signer.Infrastructure.Options;
|
||||
using StellaOps.Signer.KeyManagement;
|
||||
using StellaOps.Signer.WebService.Endpoints;
|
||||
using StellaOps.Signer.WebService.Security;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
@@ -14,13 +16,31 @@ builder.Services.AddAuthentication(StubBearerAuthenticationDefaults.Authenticati
|
||||
StubBearerAuthenticationDefaults.AuthenticationScheme,
|
||||
_ => { });
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy("KeyManagement", policy => policy.RequireAuthenticatedUser());
|
||||
});
|
||||
|
||||
builder.Services.AddSignerPipeline();
|
||||
|
||||
// Configure TimeProvider for deterministic testing support
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
|
||||
var keyManagementConnection = builder.Configuration.GetConnectionString("KeyManagement");
|
||||
if (string.IsNullOrWhiteSpace(keyManagementConnection))
|
||||
{
|
||||
builder.Services.AddDbContext<KeyManagementDbContext>(options =>
|
||||
options.UseInMemoryDatabase("SignerKeyManagement"));
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddDbContext<KeyManagementDbContext>(options =>
|
||||
options.UseNpgsql(keyManagementConnection));
|
||||
}
|
||||
|
||||
builder.Services.AddScoped<IKeyRotationService, KeyRotationService>();
|
||||
builder.Services.AddScoped<ITrustAnchorManager, TrustAnchorManager>();
|
||||
|
||||
builder.Services.Configure<SignerEntitlementOptions>(options =>
|
||||
{
|
||||
// Note: Using 1-hour expiry for demo/test tokens.
|
||||
@@ -56,6 +76,7 @@ app.TryUseStellaRouter(routerOptions);
|
||||
|
||||
app.MapGet("/", () => Results.Ok("StellaOps Signer service ready."));
|
||||
app.MapSignerEndpoints();
|
||||
app.MapKeyRotationEndpoints();
|
||||
|
||||
// Refresh Router endpoint cache
|
||||
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
|
||||
|
||||
12
src/Signer/TASKS.md
Normal file
12
src/Signer/TASKS.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# StellaOps.Signer Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260107_007_SIGNER_test_stabilization.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| SIGNER-TEST-001 | DONE | Stabilize KeyManagement EF Core JSON mapping. |
|
||||
| SIGNER-TEST-002 | DONE | Fix Fulcio certificate time parsing. |
|
||||
| SIGNER-TEST-003 | DONE | Update Signer negative tests for PoE. |
|
||||
| SIGNER-TEST-004 | DONE | Run Signer tests and capture failures. |
|
||||
|
||||
@@ -2,7 +2,6 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Signer.KeyManagement.Entities;
|
||||
|
||||
@@ -76,7 +75,7 @@ public class KeyHistoryEntity
|
||||
/// Optional metadata.
|
||||
/// </summary>
|
||||
[Column("metadata", TypeName = "jsonb")]
|
||||
public JsonDocument? Metadata { get; set; }
|
||||
public string? Metadata { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When this record was created.
|
||||
@@ -129,13 +128,13 @@ public class KeyAuditLogEntity
|
||||
/// The old state before the operation.
|
||||
/// </summary>
|
||||
[Column("old_state", TypeName = "jsonb")]
|
||||
public JsonDocument? OldState { get; set; }
|
||||
public string? OldState { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The new state after the operation.
|
||||
/// </summary>
|
||||
[Column("new_state", TypeName = "jsonb")]
|
||||
public JsonDocument? NewState { get; set; }
|
||||
public string? NewState { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for the operation.
|
||||
@@ -147,13 +146,13 @@ public class KeyAuditLogEntity
|
||||
/// Additional metadata about the operation.
|
||||
/// </summary>
|
||||
[Column("metadata", TypeName = "jsonb")]
|
||||
public JsonDocument? Metadata { get; set; }
|
||||
public string? Metadata { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional details about the operation.
|
||||
/// </summary>
|
||||
[Column("details", TypeName = "jsonb")]
|
||||
public JsonDocument? Details { get; set; }
|
||||
public string? Details { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// IP address of the requestor.
|
||||
|
||||
@@ -30,13 +30,13 @@ public class TrustAnchorEntity
|
||||
/// Currently allowed key IDs.
|
||||
/// </summary>
|
||||
[Column("allowed_key_ids", TypeName = "text[]")]
|
||||
public IList<string>? AllowedKeyIds { get; set; }
|
||||
public List<string>? AllowedKeyIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Allowed predicate types (null = all).
|
||||
/// </summary>
|
||||
[Column("allowed_predicate_types", TypeName = "text[]")]
|
||||
public IList<string>? AllowedPredicateTypes { get; set; }
|
||||
public List<string>? AllowedPredicateTypes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy reference.
|
||||
@@ -54,7 +54,7 @@ public class TrustAnchorEntity
|
||||
/// Revoked key IDs (still valid for historical proofs).
|
||||
/// </summary>
|
||||
[Column("revoked_key_ids", TypeName = "text[]")]
|
||||
public IList<string>? RevokedKeyIds { get; set; }
|
||||
public List<string>? RevokedKeyIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the anchor is active.
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -62,9 +63,15 @@ public sealed class KeyRotationService : IKeyRotationService
|
||||
return FailedResult("Algorithm is required.", [], []);
|
||||
}
|
||||
|
||||
if (_options.AllowedAlgorithms.Count > 0 &&
|
||||
!_options.AllowedAlgorithms.Contains(request.Algorithm, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return FailedResult($"Algorithm '{request.Algorithm}' is not supported.", [], []);
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
|
||||
await using var transaction = await BeginTransactionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -103,7 +110,10 @@ public sealed class KeyRotationService : IKeyRotationService
|
||||
|
||||
// Update anchor's allowed key IDs
|
||||
var allowedKeys = anchor.AllowedKeyIds?.ToList() ?? [];
|
||||
allowedKeys.Add(request.KeyId);
|
||||
if (!allowedKeys.Contains(request.KeyId))
|
||||
{
|
||||
allowedKeys.Add(request.KeyId);
|
||||
}
|
||||
anchor.AllowedKeyIds = allowedKeys;
|
||||
anchor.UpdatedAt = now;
|
||||
|
||||
@@ -123,7 +133,10 @@ public sealed class KeyRotationService : IKeyRotationService
|
||||
_dbContext.KeyAuditLog.Add(auditEntry);
|
||||
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
await transaction.CommitAsync(ct);
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.CommitAsync(ct);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Added key {KeyId} to anchor {AnchorId}. Audit log: {AuditLogId}",
|
||||
@@ -141,7 +154,10 @@ public sealed class KeyRotationService : IKeyRotationService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
}
|
||||
_logger.LogError(ex, "Failed to add key {KeyId} to anchor {AnchorId}", request.KeyId, anchorId);
|
||||
return FailedResult($"Failed to add key: {ex.Message}", [], []);
|
||||
}
|
||||
@@ -168,7 +184,7 @@ public sealed class KeyRotationService : IKeyRotationService
|
||||
|
||||
var effectiveAt = request.EffectiveAt ?? _timeProvider.GetUtcNow();
|
||||
|
||||
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
|
||||
await using var transaction = await BeginTransactionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -201,7 +217,7 @@ public sealed class KeyRotationService : IKeyRotationService
|
||||
|
||||
// Remove from allowed keys
|
||||
var allowedKeys = anchor.AllowedKeyIds?.ToList() ?? [];
|
||||
allowedKeys.Remove(keyId);
|
||||
allowedKeys.RemoveAll(k => string.Equals(k, keyId, StringComparison.Ordinal));
|
||||
anchor.AllowedKeyIds = allowedKeys;
|
||||
|
||||
// Add to revoked keys
|
||||
@@ -226,7 +242,10 @@ public sealed class KeyRotationService : IKeyRotationService
|
||||
_dbContext.KeyAuditLog.Add(auditEntry);
|
||||
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
await transaction.CommitAsync(ct);
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.CommitAsync(ct);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Revoked key {KeyId} from anchor {AnchorId}. Reason: {Reason}. Audit log: {AuditLogId}",
|
||||
@@ -242,7 +261,10 @@ public sealed class KeyRotationService : IKeyRotationService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
}
|
||||
_logger.LogError(ex, "Failed to revoke key {KeyId} from anchor {AnchorId}", keyId, anchorId);
|
||||
return FailedResult($"Failed to revoke key: {ex.Message}", [], []);
|
||||
}
|
||||
@@ -434,6 +456,23 @@ public sealed class KeyRotationService : IKeyRotationService
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
private async ValueTask<IDbContextTransaction?> BeginTransactionAsync(CancellationToken ct)
|
||||
{
|
||||
if (IsInMemoryProvider())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await _dbContext.Database.BeginTransactionAsync(ct);
|
||||
}
|
||||
|
||||
private bool IsInMemoryProvider()
|
||||
{
|
||||
var providerName = _dbContext.Database.ProviderName;
|
||||
return providerName is not null &&
|
||||
providerName.Contains("InMemory", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static KeyRotationResult FailedResult(
|
||||
string errorMessage,
|
||||
IReadOnlyList<string> allowedKeys,
|
||||
@@ -451,6 +490,31 @@ public sealed class KeyRotationService : IKeyRotationService
|
||||
/// </summary>
|
||||
public sealed class KeyRotationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Allowed signing algorithms for key rotation.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> AllowedAlgorithms { get; set; } =
|
||||
[
|
||||
"ES256",
|
||||
"ES384",
|
||||
"ES512",
|
||||
"RS256",
|
||||
"RS384",
|
||||
"RS512",
|
||||
"PS256",
|
||||
"PS384",
|
||||
"PS512",
|
||||
"ED25519",
|
||||
"EdDSA",
|
||||
"SM2",
|
||||
"GOST12-256",
|
||||
"GOST12-512",
|
||||
"DILITHIUM3",
|
||||
"FALCON512",
|
||||
"RSA-2048",
|
||||
"SHA1-RSA"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Default actor for audit log entries when not specified.
|
||||
/// </summary>
|
||||
|
||||
@@ -80,7 +80,8 @@ public sealed class TrustAnchorManager : ITrustAnchorManager
|
||||
if (PurlPatternMatcher.Matches(anchor.PurlPattern, purl))
|
||||
{
|
||||
var specificity = PurlPatternMatcher.GetSpecificity(anchor.PurlPattern);
|
||||
if (specificity > bestSpecificity)
|
||||
if (specificity > bestSpecificity ||
|
||||
(specificity == bestSpecificity && IsMoreSpecificPattern(anchor.PurlPattern, bestMatch?.PurlPattern)))
|
||||
{
|
||||
bestMatch = anchor;
|
||||
bestSpecificity = specificity;
|
||||
@@ -286,6 +287,38 @@ public sealed class TrustAnchorManager : ITrustAnchorManager
|
||||
UpdatedAt = entity.UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsMoreSpecificPattern(string candidate, string? current)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(current))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var candidateLiteral = CountLiteralCharacters(candidate);
|
||||
var currentLiteral = CountLiteralCharacters(current);
|
||||
|
||||
if (candidateLiteral != currentLiteral)
|
||||
{
|
||||
return candidateLiteral > currentLiteral;
|
||||
}
|
||||
|
||||
return candidate.Length > current.Length;
|
||||
}
|
||||
|
||||
private static int CountLiteralCharacters(string pattern)
|
||||
{
|
||||
var count = 0;
|
||||
foreach (var ch in pattern)
|
||||
{
|
||||
if (ch != '*' && ch != '?')
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -201,12 +201,15 @@ public sealed class HttpFulcioClient : IFulcioClient
|
||||
|
||||
var identity = ExtractIdentity(x509Cert);
|
||||
|
||||
var notBefore = new DateTimeOffset(x509Cert.NotBefore.ToUniversalTime());
|
||||
var notAfter = new DateTimeOffset(x509Cert.NotAfter.ToUniversalTime());
|
||||
|
||||
return new FulcioCertificateResult(
|
||||
Certificate: leafCertBytes,
|
||||
CertificateChain: chainCertsBytes,
|
||||
SignedCertificateTimestamp: fulcioResponse.SignedCertificateEmbeddedSct?.Sct ?? string.Empty,
|
||||
NotBefore: new DateTimeOffset(x509Cert.NotBefore, TimeSpan.Zero),
|
||||
NotAfter: new DateTimeOffset(x509Cert.NotAfter, TimeSpan.Zero),
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
Identity: identity);
|
||||
}
|
||||
|
||||
|
||||
@@ -213,8 +213,12 @@ public sealed class CertificateChainValidator : ICertificateChainValidator
|
||||
// Build the chain for validation
|
||||
using var chainBuilder = new X509Chain();
|
||||
chainBuilder.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; // Fulcio certs are short-lived
|
||||
chainBuilder.ChainPolicy.VerificationTime = now.DateTime;
|
||||
chainBuilder.ChainPolicy.VerificationTime = now.UtcDateTime;
|
||||
chainBuilder.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
if (_trustedRoots.Count > 0)
|
||||
{
|
||||
chainBuilder.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
|
||||
}
|
||||
|
||||
// Add trusted roots
|
||||
foreach (var root in _trustedRoots)
|
||||
|
||||
@@ -6,11 +6,9 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestation;
|
||||
using StellaOps.Signer.Core;
|
||||
|
||||
namespace StellaOps.Signer.Keyless;
|
||||
@@ -21,6 +19,9 @@ namespace StellaOps.Signer.Keyless;
|
||||
/// </summary>
|
||||
public sealed class KeylessDsseSigner : IDsseSigner, IDisposable
|
||||
{
|
||||
private const string DssePayloadType = "application/vnd.in-toto+json";
|
||||
private const string InTotoStatementTypeV1 = "https://in-toto.io/Statement/v1";
|
||||
|
||||
private readonly IEphemeralKeyGenerator _keyGenerator;
|
||||
private readonly IFulcioClient _fulcioClient;
|
||||
private readonly IOidcTokenProvider _tokenProvider;
|
||||
@@ -86,8 +87,8 @@ public sealed class KeylessDsseSigner : IDsseSigner, IDisposable
|
||||
_logger.LogDebug("Generated ephemeral {Algorithm} key pair", keyPair.Algorithm);
|
||||
|
||||
// Step 3: Serialize the in-toto statement
|
||||
var statement = BuildInTotoStatement(request);
|
||||
var statementBytes = JsonSerializer.SerializeToUtf8Bytes(statement, InTotoJsonOptions);
|
||||
var statementType = ResolveStatementType(request.PredicateType);
|
||||
var statementBytes = SignerStatementBuilder.BuildStatementPayload(request, statementType);
|
||||
|
||||
// Step 4: Create proof of possession and request certificate from Fulcio
|
||||
var proofOfPossession = CreateProofOfPossession(statementBytes, keyPair);
|
||||
@@ -106,7 +107,7 @@ public sealed class KeylessDsseSigner : IDsseSigner, IDisposable
|
||||
certResult.NotAfter);
|
||||
|
||||
// Step 5: Create DSSE signature using the ephemeral key
|
||||
var pae = CreatePreAuthenticationEncoding(request.PredicateType, statementBytes);
|
||||
var pae = DsseHelper.PreAuthenticationEncoding(DssePayloadType, statementBytes);
|
||||
var signature = keyPair.Sign(pae);
|
||||
|
||||
// Step 6: Build the signing bundle
|
||||
@@ -125,22 +126,14 @@ public sealed class KeylessDsseSigner : IDsseSigner, IDisposable
|
||||
return bundle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an in-toto statement from the signing request.
|
||||
/// </summary>
|
||||
private static InTotoStatement BuildInTotoStatement(SigningRequest request)
|
||||
private static string ResolveStatementType(string predicateType)
|
||||
{
|
||||
return new InTotoStatement
|
||||
if (string.Equals(predicateType, DssePayloadType, StringComparison.Ordinal))
|
||||
{
|
||||
Type = "https://in-toto.io/Statement/v0.1",
|
||||
PredicateType = request.PredicateType,
|
||||
Subject = request.Subjects.Select(s => new InTotoSubject
|
||||
{
|
||||
Name = s.Name,
|
||||
Digest = s.Digest
|
||||
}).ToList(),
|
||||
Predicate = request.Predicate
|
||||
};
|
||||
return InTotoStatementTypeV1;
|
||||
}
|
||||
|
||||
return SignerStatementBuilder.GetRecommendedStatementType(predicateType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -156,7 +149,7 @@ public sealed class KeylessDsseSigner : IDsseSigner, IDisposable
|
||||
// Build DSSE envelope
|
||||
var dsseEnvelope = new DsseEnvelope(
|
||||
Payload: Convert.ToBase64String(statementBytes),
|
||||
PayloadType: request.PredicateType,
|
||||
PayloadType: DssePayloadType,
|
||||
Signatures:
|
||||
[
|
||||
new DsseSignature(
|
||||
@@ -188,39 +181,6 @@ public sealed class KeylessDsseSigner : IDsseSigner, IDisposable
|
||||
return new SigningBundle(dsseEnvelope, metadata);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the Pre-Authentication Encoding (PAE) for DSSE.
|
||||
/// PAE(payloadType, payload) = "DSSEv1" + SP + LEN(payloadType) + SP + payloadType + SP + LEN(payload) + SP + payload
|
||||
/// </summary>
|
||||
private static byte[] CreatePreAuthenticationEncoding(string payloadType, byte[] payload)
|
||||
{
|
||||
var payloadTypeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
using var writer = new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true);
|
||||
|
||||
// Write "DSSEv1 "
|
||||
writer.Write(Encoding.UTF8.GetBytes("DSSEv1 "));
|
||||
|
||||
// Write length of payload type (8 bytes, little-endian)
|
||||
writer.Write((long)payloadTypeBytes.Length);
|
||||
writer.Write((byte)' ');
|
||||
|
||||
// Write payload type
|
||||
writer.Write(payloadTypeBytes);
|
||||
writer.Write((byte)' ');
|
||||
|
||||
// Write length of payload (8 bytes, little-endian)
|
||||
writer.Write((long)payload.Length);
|
||||
writer.Write((byte)' ');
|
||||
|
||||
// Write payload
|
||||
writer.Write(payload);
|
||||
|
||||
writer.Flush();
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a proof of possession by signing a hash of the payload.
|
||||
/// This proves possession of the private key to Fulcio.
|
||||
@@ -240,35 +200,9 @@ public sealed class KeylessDsseSigner : IDsseSigner, IDisposable
|
||||
return $"SHA256:{Convert.ToHexString(fingerprint).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions InTotoJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement structure.
|
||||
/// </summary>
|
||||
internal sealed class InTotoStatement
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public required string PredicateType { get; init; }
|
||||
public required IReadOnlyList<InTotoSubject> Subject { get; init; }
|
||||
public required JsonDocument Predicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-toto subject (artifact reference).
|
||||
/// </summary>
|
||||
internal sealed class InTotoSubject
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required IReadOnlyDictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\Attestor\StellaOps.Attestation\StellaOps.Attestation.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Signer\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user