Add property-based tests for SBOM/VEX document ordering and Unicode normalization determinism
- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency. - Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling. - Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies. - Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification. - Create validation script for CI/CD templates ensuring all required files and structures are present.
This commit is contained in:
@@ -0,0 +1,517 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// KeylessSigningIntegrationTests.cs
|
||||
// Sprint: SPRINT_20251226_001_SIGNER_fulcio_keyless_client
|
||||
// Tasks: 0017, 0018 - Integration tests for full keyless signing flow
|
||||
// Description: End-to-end integration tests with mock Fulcio server
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using StellaOps.Signer.Core;
|
||||
using StellaOps.Signer.Keyless;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signer.Tests.Keyless;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the full keyless signing flow.
|
||||
/// Validates the complete pipeline: OIDC token -> Fulcio cert -> DSSE signing.
|
||||
/// </summary>
|
||||
public sealed class KeylessSigningIntegrationTests : IDisposable
|
||||
{
|
||||
private readonly MockFulcioServer _mockFulcio;
|
||||
private readonly SignerKeylessOptions _options;
|
||||
private readonly List<IDisposable> _disposables = [];
|
||||
|
||||
public KeylessSigningIntegrationTests()
|
||||
{
|
||||
_mockFulcio = new MockFulcioServer();
|
||||
|
||||
_options = new SignerKeylessOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Fulcio = new FulcioOptions
|
||||
{
|
||||
Url = "https://fulcio.test",
|
||||
Timeout = TimeSpan.FromSeconds(30),
|
||||
Retries = 3,
|
||||
BackoffBase = TimeSpan.FromMilliseconds(10),
|
||||
BackoffMax = TimeSpan.FromMilliseconds(100)
|
||||
},
|
||||
Algorithms = new AlgorithmOptions
|
||||
{
|
||||
Preferred = KeylessAlgorithms.EcdsaP256,
|
||||
Allowed = [KeylessAlgorithms.EcdsaP256, KeylessAlgorithms.Ed25519]
|
||||
},
|
||||
Certificate = new CertificateOptions
|
||||
{
|
||||
ValidateChain = false, // Disable for tests with self-signed certs
|
||||
RequireSct = false
|
||||
},
|
||||
Identity = new IdentityOptions
|
||||
{
|
||||
ExpectedIssuers = [],
|
||||
ExpectedSubjectPatterns = []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var d in _disposables)
|
||||
d.Dispose();
|
||||
_mockFulcio.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullKeylessFlow_ValidOidcToken_ProducesDsseBundle()
|
||||
{
|
||||
// Arrange
|
||||
var keyGenerator = new EphemeralKeyGenerator(NullLogger<EphemeralKeyGenerator>.Instance);
|
||||
var fulcioClient = CreateMockFulcioClient();
|
||||
var tokenProvider = CreateMockTokenProvider("test@example.com");
|
||||
|
||||
var signer = new KeylessDsseSigner(
|
||||
keyGenerator,
|
||||
fulcioClient,
|
||||
tokenProvider,
|
||||
Options.Create(_options),
|
||||
NullLogger<KeylessDsseSigner>.Instance);
|
||||
_disposables.Add(signer);
|
||||
|
||||
var request = CreateSigningRequest();
|
||||
var entitlement = CreateEntitlement();
|
||||
var caller = CreateCallerContext();
|
||||
|
||||
// Act
|
||||
var bundle = await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
bundle.Should().NotBeNull();
|
||||
bundle.Envelope.Should().NotBeNull();
|
||||
bundle.Envelope.PayloadType.Should().Be("application/vnd.in-toto+json");
|
||||
bundle.Envelope.Payload.Should().NotBeNullOrEmpty();
|
||||
bundle.Envelope.Signatures.Should().HaveCount(1);
|
||||
bundle.Envelope.Signatures[0].Sig.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullKeylessFlow_ProducesValidInTotoStatement()
|
||||
{
|
||||
// Arrange
|
||||
var keyGenerator = new EphemeralKeyGenerator(NullLogger<EphemeralKeyGenerator>.Instance);
|
||||
var fulcioClient = CreateMockFulcioClient();
|
||||
var tokenProvider = CreateMockTokenProvider("test@example.com");
|
||||
|
||||
var signer = new KeylessDsseSigner(
|
||||
keyGenerator,
|
||||
fulcioClient,
|
||||
tokenProvider,
|
||||
Options.Create(_options),
|
||||
NullLogger<KeylessDsseSigner>.Instance);
|
||||
_disposables.Add(signer);
|
||||
|
||||
var request = CreateSigningRequest();
|
||||
var entitlement = CreateEntitlement();
|
||||
var caller = CreateCallerContext();
|
||||
|
||||
// Act
|
||||
var bundle = await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
|
||||
// Assert - decode and validate the in-toto statement
|
||||
var payloadBytes = Convert.FromBase64String(bundle.Envelope.Payload);
|
||||
var statement = JsonDocument.Parse(payloadBytes);
|
||||
|
||||
statement.RootElement.GetProperty("_type").GetString()
|
||||
.Should().Be("https://in-toto.io/Statement/v1");
|
||||
|
||||
statement.RootElement.GetProperty("subject").GetArrayLength()
|
||||
.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullKeylessFlow_IncludesCertificateChain()
|
||||
{
|
||||
// Arrange
|
||||
var keyGenerator = new EphemeralKeyGenerator(NullLogger<EphemeralKeyGenerator>.Instance);
|
||||
var fulcioClient = CreateMockFulcioClient();
|
||||
var tokenProvider = CreateMockTokenProvider("test@example.com");
|
||||
|
||||
var signer = new KeylessDsseSigner(
|
||||
keyGenerator,
|
||||
fulcioClient,
|
||||
tokenProvider,
|
||||
Options.Create(_options),
|
||||
NullLogger<KeylessDsseSigner>.Instance);
|
||||
_disposables.Add(signer);
|
||||
|
||||
var request = CreateSigningRequest();
|
||||
var entitlement = CreateEntitlement();
|
||||
var caller = CreateCallerContext();
|
||||
|
||||
// Act
|
||||
var bundle = await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
bundle.Metadata.CertificateChain.Should().NotBeNullOrEmpty();
|
||||
bundle.Metadata.CertificateChain.Should().HaveCountGreaterOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullKeylessFlow_IncludesSigningIdentity()
|
||||
{
|
||||
// Arrange
|
||||
var keyGenerator = new EphemeralKeyGenerator(NullLogger<EphemeralKeyGenerator>.Instance);
|
||||
var fulcioClient = CreateMockFulcioClient();
|
||||
var tokenProvider = CreateMockTokenProvider("ci@github.com");
|
||||
|
||||
var signer = new KeylessDsseSigner(
|
||||
keyGenerator,
|
||||
fulcioClient,
|
||||
tokenProvider,
|
||||
Options.Create(_options),
|
||||
NullLogger<KeylessDsseSigner>.Instance);
|
||||
_disposables.Add(signer);
|
||||
|
||||
var request = CreateSigningRequest();
|
||||
var entitlement = CreateEntitlement();
|
||||
var caller = CreateCallerContext();
|
||||
|
||||
// Act
|
||||
var bundle = await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
bundle.Metadata.Identity.Should().NotBeNull();
|
||||
bundle.Metadata.Identity.Mode.Should().Be("keyless");
|
||||
bundle.Metadata.Identity.Subject.Should().Be("ci@github.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullKeylessFlow_EachSigningProducesDifferentSignature()
|
||||
{
|
||||
// Arrange - ephemeral keys mean different signatures each time
|
||||
var keyGenerator = new EphemeralKeyGenerator(NullLogger<EphemeralKeyGenerator>.Instance);
|
||||
var fulcioClient = CreateMockFulcioClient();
|
||||
var tokenProvider = CreateMockTokenProvider("test@example.com");
|
||||
|
||||
var signer = new KeylessDsseSigner(
|
||||
keyGenerator,
|
||||
fulcioClient,
|
||||
tokenProvider,
|
||||
Options.Create(_options),
|
||||
NullLogger<KeylessDsseSigner>.Instance);
|
||||
_disposables.Add(signer);
|
||||
|
||||
var request = CreateSigningRequest();
|
||||
var entitlement = CreateEntitlement();
|
||||
var caller = CreateCallerContext();
|
||||
|
||||
// Act
|
||||
var bundle1 = await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
var bundle2 = await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
|
||||
// Assert - different ephemeral keys = different signatures
|
||||
bundle1.Envelope.Signatures[0].Sig.Should()
|
||||
.NotBe(bundle2.Envelope.Signatures[0].Sig,
|
||||
"each signing should use a new ephemeral key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullKeylessFlow_FulcioUnavailable_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var keyGenerator = new EphemeralKeyGenerator(NullLogger<EphemeralKeyGenerator>.Instance);
|
||||
var fulcioClient = Substitute.For<IFulcioClient>();
|
||||
fulcioClient.GetCertificateAsync(Arg.Any<FulcioCertificateRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns<FulcioCertificateResult>(_ => throw new FulcioUnavailableException(
|
||||
"https://fulcio.test", "Service unavailable"));
|
||||
|
||||
var tokenProvider = CreateMockTokenProvider("test@example.com");
|
||||
|
||||
var signer = new KeylessDsseSigner(
|
||||
keyGenerator,
|
||||
fulcioClient,
|
||||
tokenProvider,
|
||||
Options.Create(_options),
|
||||
NullLogger<KeylessDsseSigner>.Instance);
|
||||
_disposables.Add(signer);
|
||||
|
||||
var request = CreateSigningRequest();
|
||||
var entitlement = CreateEntitlement();
|
||||
var caller = CreateCallerContext();
|
||||
|
||||
// Act
|
||||
var act = async () => await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<FulcioUnavailableException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullKeylessFlow_OidcTokenInvalid_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var keyGenerator = new EphemeralKeyGenerator(NullLogger<EphemeralKeyGenerator>.Instance);
|
||||
var fulcioClient = CreateMockFulcioClient();
|
||||
|
||||
var tokenProvider = Substitute.For<IOidcTokenProvider>();
|
||||
tokenProvider.AcquireTokenAsync(Arg.Any<CancellationToken>())
|
||||
.Returns<OidcTokenResult>(_ => throw new OidcTokenAcquisitionException(
|
||||
"https://auth.test", "Token expired"));
|
||||
|
||||
var signer = new KeylessDsseSigner(
|
||||
keyGenerator,
|
||||
fulcioClient,
|
||||
tokenProvider,
|
||||
Options.Create(_options),
|
||||
NullLogger<KeylessDsseSigner>.Instance);
|
||||
_disposables.Add(signer);
|
||||
|
||||
var request = CreateSigningRequest();
|
||||
var entitlement = CreateEntitlement();
|
||||
var caller = CreateCallerContext();
|
||||
|
||||
// Act
|
||||
var act = async () => await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<OidcTokenAcquisitionException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignedBundle_CanBeVerified_WithEmbeddedCertificate()
|
||||
{
|
||||
// Arrange
|
||||
var keyGenerator = new EphemeralKeyGenerator(NullLogger<EphemeralKeyGenerator>.Instance);
|
||||
var fulcioClient = CreateMockFulcioClient();
|
||||
var tokenProvider = CreateMockTokenProvider("test@example.com");
|
||||
|
||||
var signer = new KeylessDsseSigner(
|
||||
keyGenerator,
|
||||
fulcioClient,
|
||||
tokenProvider,
|
||||
Options.Create(_options),
|
||||
NullLogger<KeylessDsseSigner>.Instance);
|
||||
_disposables.Add(signer);
|
||||
|
||||
var request = CreateSigningRequest();
|
||||
var entitlement = CreateEntitlement();
|
||||
var caller = CreateCallerContext();
|
||||
|
||||
// Act
|
||||
var bundle = await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
|
||||
// Assert - the bundle should contain all data needed for verification
|
||||
bundle.Should().NotBeNull();
|
||||
bundle.Metadata.CertificateChain.Should().NotBeEmpty(
|
||||
"bundle must include certificate chain for verification");
|
||||
bundle.Envelope.Signatures[0].Sig.Should().NotBeNullOrEmpty(
|
||||
"bundle must include signature");
|
||||
bundle.Envelope.Payload.Should().NotBeNullOrEmpty(
|
||||
"bundle must include payload for verification");
|
||||
|
||||
// Verify the certificate chain can be parsed
|
||||
var leafCertBase64 = bundle.Metadata.CertificateChain.First();
|
||||
var act = () =>
|
||||
{
|
||||
var pemContent = Encoding.UTF8.GetString(Convert.FromBase64String(leafCertBase64));
|
||||
return true;
|
||||
};
|
||||
act.Should().NotThrow("certificate should be valid base64");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultipleSubjects_AllIncludedInStatement()
|
||||
{
|
||||
// Arrange
|
||||
var keyGenerator = new EphemeralKeyGenerator(NullLogger<EphemeralKeyGenerator>.Instance);
|
||||
var fulcioClient = CreateMockFulcioClient();
|
||||
var tokenProvider = CreateMockTokenProvider("test@example.com");
|
||||
|
||||
var signer = new KeylessDsseSigner(
|
||||
keyGenerator,
|
||||
fulcioClient,
|
||||
tokenProvider,
|
||||
Options.Create(_options),
|
||||
NullLogger<KeylessDsseSigner>.Instance);
|
||||
_disposables.Add(signer);
|
||||
|
||||
// Create request with multiple subjects
|
||||
var subjects = new List<SigningSubject>
|
||||
{
|
||||
new("artifact-1", new Dictionary<string, string> { ["sha256"] = "abc123" }),
|
||||
new("artifact-2", new Dictionary<string, string> { ["sha256"] = "def456" }),
|
||||
new("artifact-3", new Dictionary<string, string> { ["sha256"] = "ghi789" })
|
||||
};
|
||||
|
||||
var predicate = JsonDocument.Parse("{\"verdict\": \"pass\"}");
|
||||
var request = new SigningRequest(
|
||||
Subjects: subjects,
|
||||
PredicateType: "application/vnd.in-toto+json",
|
||||
Predicate: predicate,
|
||||
ScannerImageDigest: "sha256:test",
|
||||
ProofOfEntitlement: new ProofOfEntitlement(SignerPoEFormat.Jwt, "test"),
|
||||
Options: new SigningOptions(SigningMode.Keyless, null, "full"));
|
||||
|
||||
var entitlement = CreateEntitlement();
|
||||
var caller = CreateCallerContext();
|
||||
|
||||
// Act
|
||||
var bundle = await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var payloadBytes = Convert.FromBase64String(bundle.Envelope.Payload);
|
||||
var statement = JsonDocument.Parse(payloadBytes);
|
||||
statement.RootElement.GetProperty("subject").GetArrayLength().Should().Be(3);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private IFulcioClient CreateMockFulcioClient()
|
||||
{
|
||||
var client = Substitute.For<IFulcioClient>();
|
||||
client.GetCertificateAsync(Arg.Any<FulcioCertificateRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(callInfo =>
|
||||
{
|
||||
var request = callInfo.Arg<FulcioCertificateRequest>();
|
||||
return _mockFulcio.IssueCertificate(request);
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
private static IOidcTokenProvider CreateMockTokenProvider(string subject)
|
||||
{
|
||||
var provider = Substitute.For<IOidcTokenProvider>();
|
||||
provider.AcquireTokenAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new OidcTokenResult
|
||||
{
|
||||
IdentityToken = $"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rlc3QuYXV0aCIsInN1YiI6Intsubject}\",\"ZXhwIjo5OTk5OTk5OTk5fQ.sig",
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
|
||||
Subject = subject,
|
||||
Email = subject
|
||||
});
|
||||
return provider;
|
||||
}
|
||||
|
||||
private static SigningRequest CreateSigningRequest()
|
||||
{
|
||||
var predicate = JsonDocument.Parse("""
|
||||
{
|
||||
"verdict": "pass",
|
||||
"gates": [{"name": "drift", "result": "pass"}]
|
||||
}
|
||||
""");
|
||||
|
||||
return new SigningRequest(
|
||||
Subjects:
|
||||
[
|
||||
new SigningSubject("test-artifact", new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
})
|
||||
],
|
||||
PredicateType: "application/vnd.in-toto+json",
|
||||
Predicate: predicate,
|
||||
ScannerImageDigest: "sha256:abc123",
|
||||
ProofOfEntitlement: new ProofOfEntitlement(SignerPoEFormat.Jwt, "test-poe"),
|
||||
Options: new SigningOptions(SigningMode.Keyless, null, "full"));
|
||||
}
|
||||
|
||||
private static ProofOfEntitlementResult CreateEntitlement()
|
||||
{
|
||||
return new ProofOfEntitlementResult(
|
||||
LicenseId: "test-license",
|
||||
CustomerId: "test-customer",
|
||||
Plan: "enterprise",
|
||||
MaxArtifactBytes: 1000000,
|
||||
QpsLimit: 100,
|
||||
QpsRemaining: 50,
|
||||
ExpiresAtUtc: DateTimeOffset.UtcNow.AddDays(30));
|
||||
}
|
||||
|
||||
private static CallerContext CreateCallerContext()
|
||||
{
|
||||
return new CallerContext(
|
||||
Subject: "test@test.com",
|
||||
Tenant: "test-tenant",
|
||||
Scopes: ["signer:sign"],
|
||||
Audiences: ["signer"],
|
||||
SenderBinding: null,
|
||||
ClientCertificateThumbprint: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock Fulcio server for integration testing.
|
||||
/// </summary>
|
||||
private sealed class MockFulcioServer : IDisposable
|
||||
{
|
||||
private readonly X509Certificate2 _rootCa;
|
||||
private readonly RSA _rootKey;
|
||||
|
||||
public MockFulcioServer()
|
||||
{
|
||||
_rootKey = RSA.Create(2048);
|
||||
var request = new CertificateRequest(
|
||||
"CN=Mock Fulcio Root CA, O=Test",
|
||||
_rootKey,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
request.CertificateExtensions.Add(
|
||||
new X509BasicConstraintsExtension(true, false, 0, true));
|
||||
|
||||
_rootCa = request.CreateSelfSigned(
|
||||
DateTimeOffset.UtcNow.AddYears(-1),
|
||||
DateTimeOffset.UtcNow.AddYears(10));
|
||||
}
|
||||
|
||||
public FulcioCertificateResult IssueCertificate(FulcioCertificateRequest request)
|
||||
{
|
||||
// Create a leaf certificate signed by our mock CA
|
||||
using var leafKey = RSA.Create(2048);
|
||||
var leafRequest = new CertificateRequest(
|
||||
"CN=Test Subject, 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");
|
||||
leafRequest.CertificateExtensions.Add(new X509Extension(issuerOid, issuerBytes, false));
|
||||
|
||||
var serial = new byte[16];
|
||||
RandomNumberGenerator.Fill(serial);
|
||||
|
||||
var leafCert = leafRequest.Create(
|
||||
_rootCa.CopyWithPrivateKey(_rootKey),
|
||||
DateTimeOffset.UtcNow.AddMinutes(-1),
|
||||
DateTimeOffset.UtcNow.AddMinutes(10),
|
||||
serial);
|
||||
|
||||
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),
|
||||
Identity: new FulcioIdentity(
|
||||
Issuer: "https://test.auth",
|
||||
Subject: "test@test.com",
|
||||
SubjectAlternativeName: "test@test.com"));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_rootCa.Dispose();
|
||||
_rootKey.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user