518 lines
19 KiB
C#
518 lines
19 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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].Signature.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].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<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].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<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.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<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();
|
|
}
|
|
}
|
|
}
|