// ----------------------------------------------------------------------------- // 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; /// /// Integration tests for the full keyless signing flow. /// Validates the complete pipeline: OIDC token -> Fulcio cert -> DSSE signing. /// public sealed class KeylessSigningIntegrationTests : IDisposable { private readonly MockFulcioServer _mockFulcio; private readonly SignerKeylessOptions _options; private readonly List _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.Instance); var fulcioClient = CreateMockFulcioClient(); var tokenProvider = CreateMockTokenProvider("test@example.com"); var signer = new KeylessDsseSigner( keyGenerator, fulcioClient, tokenProvider, Options.Create(_options), NullLogger.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].Signature.Should().NotBeNullOrEmpty(); } [Fact] public async Task FullKeylessFlow_ProducesValidInTotoStatement() { // Arrange var keyGenerator = new EphemeralKeyGenerator(NullLogger.Instance); var fulcioClient = CreateMockFulcioClient(); var tokenProvider = CreateMockTokenProvider("test@example.com"); var signer = new KeylessDsseSigner( keyGenerator, fulcioClient, tokenProvider, Options.Create(_options), NullLogger.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.Instance); var fulcioClient = CreateMockFulcioClient(); var tokenProvider = CreateMockTokenProvider("test@example.com"); var signer = new KeylessDsseSigner( keyGenerator, fulcioClient, tokenProvider, Options.Create(_options), NullLogger.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.Instance); var fulcioClient = CreateMockFulcioClient(); var tokenProvider = CreateMockTokenProvider("ci@github.com"); var signer = new KeylessDsseSigner( keyGenerator, fulcioClient, tokenProvider, Options.Create(_options), NullLogger.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.Instance); var fulcioClient = CreateMockFulcioClient(); var tokenProvider = CreateMockTokenProvider("test@example.com"); var signer = new KeylessDsseSigner( keyGenerator, fulcioClient, tokenProvider, Options.Create(_options), NullLogger.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].Signature.Should() .NotBe(bundle2.Envelope.Signatures[0].Signature, "each signing should use a new ephemeral key"); } [Fact] public async Task FullKeylessFlow_FulcioUnavailable_ThrowsException() { // Arrange var keyGenerator = new EphemeralKeyGenerator(NullLogger.Instance); var fulcioClient = Substitute.For(); fulcioClient.GetCertificateAsync(Arg.Any(), Arg.Any()) .Returns(_ => 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.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(); } [Fact] public async Task FullKeylessFlow_OidcTokenInvalid_ThrowsException() { // Arrange var keyGenerator = new EphemeralKeyGenerator(NullLogger.Instance); var fulcioClient = CreateMockFulcioClient(); var tokenProvider = Substitute.For(); tokenProvider.AcquireTokenAsync(Arg.Any()) .Returns(_ => throw new OidcTokenAcquisitionException( "https://auth.test", "Token expired")); var signer = new KeylessDsseSigner( keyGenerator, fulcioClient, tokenProvider, Options.Create(_options), NullLogger.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(); } [Fact] public async Task SignedBundle_CanBeVerified_WithEmbeddedCertificate() { // Arrange var keyGenerator = new EphemeralKeyGenerator(NullLogger.Instance); var fulcioClient = CreateMockFulcioClient(); var tokenProvider = CreateMockTokenProvider("test@example.com"); var signer = new KeylessDsseSigner( keyGenerator, fulcioClient, tokenProvider, Options.Create(_options), NullLogger.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].Signature.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.Instance); var fulcioClient = CreateMockFulcioClient(); var tokenProvider = CreateMockTokenProvider("test@example.com"); var signer = new KeylessDsseSigner( keyGenerator, fulcioClient, tokenProvider, Options.Create(_options), NullLogger.Instance); _disposables.Add(signer); // Create request with multiple subjects var subjects = new List { new("artifact-1", new Dictionary { ["sha256"] = "abc123" }), new("artifact-2", new Dictionary { ["sha256"] = "def456" }), new("artifact-3", new Dictionary { ["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(); client.GetCertificateAsync(Arg.Any(), Arg.Any()) .Returns(callInfo => { var request = callInfo.Arg(); return _mockFulcio.IssueCertificate(request); }); return client; } private static IOidcTokenProvider CreateMockTokenProvider(string subject) { var provider = Substitute.For(); provider.AcquireTokenAsync(Arg.Any()) .Returns(new OidcTokenResult { IdentityToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rlc3QuYXV0aCIsInN1YiI6InRlc3Qtc3ViamVjdCIsImV4cCI6OTk5OTk5OTk5OX0.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 { ["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); } /// /// Mock Fulcio server for integration testing. /// 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(); } } }