more audit work

This commit is contained in:
master
2026-01-08 10:21:51 +02:00
parent 43c02081ef
commit 51cf4bc16c
546 changed files with 36721 additions and 4003 deletions

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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
{

View File

@@ -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

View File

@@ -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
{

View File

@@ -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(

View File

@@ -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()

View File

@@ -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
}

View File

@@ -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]

View File

@@ -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

View File

@@ -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)

View File

@@ -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);

View File

@@ -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
View 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. |

View File

@@ -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.

View File

@@ -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.

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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)

View File

@@ -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; }
}

View File

@@ -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>