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,387 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// FileSystemRootStoreTests.cs
|
||||
// Sprint: SPRINT_20251226_003_ATTESTOR_offline_verification
|
||||
// Task: 0023 - Unit tests for FileSystemRootStore
|
||||
// Description: Unit tests for file-based root certificate store
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Attestor.Offline.Abstractions;
|
||||
using StellaOps.Attestor.Offline.Services;
|
||||
|
||||
namespace StellaOps.Attestor.Offline.Tests;
|
||||
|
||||
public class FileSystemRootStoreTests : IDisposable
|
||||
{
|
||||
private readonly Mock<ILogger<FileSystemRootStore>> _loggerMock;
|
||||
private readonly string _testRootPath;
|
||||
|
||||
public FileSystemRootStoreTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<FileSystemRootStore>>();
|
||||
_testRootPath = Path.Combine(Path.GetTempPath(), $"stellaops-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_testRootPath);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_testRootPath))
|
||||
{
|
||||
Directory.Delete(_testRootPath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFulcioRootsAsync_WithNoCertificates_ReturnsEmptyCollection()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions();
|
||||
var store = CreateStore(options);
|
||||
|
||||
// Act
|
||||
var roots = await store.GetFulcioRootsAsync();
|
||||
|
||||
// Assert
|
||||
roots.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFulcioRootsAsync_WithPemFile_ReturnsCertificates()
|
||||
{
|
||||
// Arrange
|
||||
var cert = CreateTestCertificate("CN=Test Fulcio Root");
|
||||
var pemPath = Path.Combine(_testRootPath, "fulcio.pem");
|
||||
await WritePemFileAsync(pemPath, cert);
|
||||
|
||||
var options = CreateOptions(fulcioPath: pemPath);
|
||||
var store = CreateStore(options);
|
||||
|
||||
// Act
|
||||
var roots = await store.GetFulcioRootsAsync();
|
||||
|
||||
// Assert
|
||||
roots.Should().HaveCount(1);
|
||||
roots[0].Subject.Should().Be("CN=Test Fulcio Root");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFulcioRootsAsync_WithDirectory_LoadsAllPemFiles()
|
||||
{
|
||||
// Arrange
|
||||
var fulcioDir = Path.Combine(_testRootPath, "fulcio");
|
||||
Directory.CreateDirectory(fulcioDir);
|
||||
|
||||
var cert1 = CreateTestCertificate("CN=Root 1");
|
||||
var cert2 = CreateTestCertificate("CN=Root 2");
|
||||
|
||||
await WritePemFileAsync(Path.Combine(fulcioDir, "root1.pem"), cert1);
|
||||
await WritePemFileAsync(Path.Combine(fulcioDir, "root2.pem"), cert2);
|
||||
|
||||
var options = CreateOptions(fulcioPath: fulcioDir);
|
||||
var store = CreateStore(options);
|
||||
|
||||
// Act
|
||||
var roots = await store.GetFulcioRootsAsync();
|
||||
|
||||
// Assert
|
||||
roots.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFulcioRootsAsync_CachesCertificates_OnSecondCall()
|
||||
{
|
||||
// Arrange
|
||||
var cert = CreateTestCertificate("CN=Cached Root");
|
||||
var pemPath = Path.Combine(_testRootPath, "cached.pem");
|
||||
await WritePemFileAsync(pemPath, cert);
|
||||
|
||||
var options = CreateOptions(fulcioPath: pemPath);
|
||||
var store = CreateStore(options);
|
||||
|
||||
// Act
|
||||
var roots1 = await store.GetFulcioRootsAsync();
|
||||
var roots2 = await store.GetFulcioRootsAsync();
|
||||
|
||||
// Assert - same collection instance (cached)
|
||||
roots1.Should().HaveCount(1);
|
||||
roots2.Should().HaveCount(1);
|
||||
// Both calls should return same data
|
||||
roots1[0].Subject.Should().Be(roots2[0].Subject);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportRootsAsync_WithValidPem_SavesCertificates()
|
||||
{
|
||||
// Arrange
|
||||
var cert = CreateTestCertificate("CN=Imported Root");
|
||||
var sourcePath = Path.Combine(_testRootPath, "import-source.pem");
|
||||
await WritePemFileAsync(sourcePath, cert);
|
||||
|
||||
var options = CreateOptions();
|
||||
options.Value.BaseRootPath = _testRootPath;
|
||||
var store = CreateStore(options);
|
||||
|
||||
// Act
|
||||
await store.ImportRootsAsync(sourcePath, RootType.Fulcio);
|
||||
|
||||
// Assert
|
||||
var targetDir = Path.Combine(_testRootPath, "fulcio");
|
||||
Directory.Exists(targetDir).Should().BeTrue();
|
||||
Directory.EnumerateFiles(targetDir, "*.pem").Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportRootsAsync_WithMissingFile_ThrowsFileNotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions();
|
||||
var store = CreateStore(options);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<FileNotFoundException>(
|
||||
() => store.ImportRootsAsync("/nonexistent/path.pem", RootType.Fulcio));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportRootsAsync_InvalidatesCacheAfterImport()
|
||||
{
|
||||
// Arrange
|
||||
var cert1 = CreateTestCertificate("CN=Initial Root");
|
||||
var fulcioDir = Path.Combine(_testRootPath, "fulcio");
|
||||
Directory.CreateDirectory(fulcioDir);
|
||||
await WritePemFileAsync(Path.Combine(fulcioDir, "initial.pem"), cert1);
|
||||
|
||||
var options = CreateOptions(fulcioPath: fulcioDir);
|
||||
options.Value.BaseRootPath = _testRootPath;
|
||||
var store = CreateStore(options);
|
||||
|
||||
// Load initial cache
|
||||
var initialRoots = await store.GetFulcioRootsAsync();
|
||||
initialRoots.Should().HaveCount(1);
|
||||
|
||||
// Import a new certificate
|
||||
var cert2 = CreateTestCertificate("CN=Imported Root");
|
||||
var importPath = Path.Combine(_testRootPath, "import.pem");
|
||||
await WritePemFileAsync(importPath, cert2);
|
||||
|
||||
// Act
|
||||
await store.ImportRootsAsync(importPath, RootType.Fulcio);
|
||||
var updatedRoots = await store.GetFulcioRootsAsync();
|
||||
|
||||
// Assert - cache invalidated and new cert loaded
|
||||
updatedRoots.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListRootsAsync_ReturnsCorrectInfo()
|
||||
{
|
||||
// Arrange
|
||||
var cert = CreateTestCertificate("CN=Listed Root");
|
||||
var fulcioDir = Path.Combine(_testRootPath, "fulcio");
|
||||
Directory.CreateDirectory(fulcioDir);
|
||||
await WritePemFileAsync(Path.Combine(fulcioDir, "root.pem"), cert);
|
||||
|
||||
var options = CreateOptions(fulcioPath: fulcioDir);
|
||||
var store = CreateStore(options);
|
||||
|
||||
// Act
|
||||
var roots = await store.ListRootsAsync(RootType.Fulcio);
|
||||
|
||||
// Assert
|
||||
roots.Should().HaveCount(1);
|
||||
roots[0].Subject.Should().Be("CN=Listed Root");
|
||||
roots[0].RootType.Should().Be(RootType.Fulcio);
|
||||
roots[0].Thumbprint.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrgKeyByIdAsync_WithMatchingThumbprint_ReturnsCertificate()
|
||||
{
|
||||
// Arrange
|
||||
var cert = CreateTestCertificate("CN=Org Signing Key");
|
||||
var orgDir = Path.Combine(_testRootPath, "org-signing");
|
||||
Directory.CreateDirectory(orgDir);
|
||||
await WritePemFileAsync(Path.Combine(orgDir, "org.pem"), cert);
|
||||
|
||||
var options = CreateOptions(orgSigningPath: orgDir);
|
||||
var store = CreateStore(options);
|
||||
|
||||
// First, verify the cert was loaded and get its thumbprint from listing
|
||||
var orgKeys = await store.GetOrgSigningKeysAsync();
|
||||
orgKeys.Should().HaveCount(1);
|
||||
|
||||
// Get the thumbprint from the loaded certificate
|
||||
var thumbprint = ComputeThumbprint(orgKeys[0]);
|
||||
|
||||
// Act
|
||||
var found = await store.GetOrgKeyByIdAsync(thumbprint);
|
||||
|
||||
// Assert
|
||||
found.Should().NotBeNull();
|
||||
found!.Subject.Should().Be("CN=Org Signing Key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrgKeyByIdAsync_WithNoMatch_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var cert = CreateTestCertificate("CN=Org Key");
|
||||
var orgDir = Path.Combine(_testRootPath, "org-signing");
|
||||
Directory.CreateDirectory(orgDir);
|
||||
await WritePemFileAsync(Path.Combine(orgDir, "org.pem"), cert);
|
||||
|
||||
var options = CreateOptions(orgSigningPath: orgDir);
|
||||
var store = CreateStore(options);
|
||||
|
||||
// Act
|
||||
var found = await store.GetOrgKeyByIdAsync("nonexistent-key-id");
|
||||
|
||||
// Assert
|
||||
found.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRekorKeysAsync_WithPemFile_ReturnsCertificates()
|
||||
{
|
||||
// Arrange
|
||||
var cert = CreateTestCertificate("CN=Rekor Key");
|
||||
var rekorPath = Path.Combine(_testRootPath, "rekor.pem");
|
||||
await WritePemFileAsync(rekorPath, cert);
|
||||
|
||||
var options = CreateOptions(rekorPath: rekorPath);
|
||||
var store = CreateStore(options);
|
||||
|
||||
// Act
|
||||
var keys = await store.GetRekorKeysAsync();
|
||||
|
||||
// Assert
|
||||
keys.Should().HaveCount(1);
|
||||
keys[0].Subject.Should().Be("CN=Rekor Key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadPem_WithMultipleCertificates_ReturnsAll()
|
||||
{
|
||||
// Arrange
|
||||
var cert1 = CreateTestCertificate("CN=Cert 1");
|
||||
var cert2 = CreateTestCertificate("CN=Cert 2");
|
||||
var cert3 = CreateTestCertificate("CN=Cert 3");
|
||||
|
||||
var pemPath = Path.Combine(_testRootPath, "multi.pem");
|
||||
await WriteMultiplePemFileAsync(pemPath, [cert1, cert2, cert3]);
|
||||
|
||||
var options = CreateOptions(fulcioPath: pemPath);
|
||||
var store = CreateStore(options);
|
||||
|
||||
// Act
|
||||
var roots = await store.GetFulcioRootsAsync();
|
||||
|
||||
// Assert
|
||||
roots.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFulcioRootsAsync_WithOfflineKitPath_LoadsFromKit()
|
||||
{
|
||||
// Arrange
|
||||
var offlineKitPath = Path.Combine(_testRootPath, "offline-kit");
|
||||
var fulcioKitDir = Path.Combine(offlineKitPath, "roots", "fulcio");
|
||||
Directory.CreateDirectory(fulcioKitDir);
|
||||
|
||||
var cert = CreateTestCertificate("CN=Offline Kit Root");
|
||||
await WritePemFileAsync(Path.Combine(fulcioKitDir, "root.pem"), cert);
|
||||
|
||||
var options = Options.Create(new OfflineRootStoreOptions
|
||||
{
|
||||
BaseRootPath = _testRootPath,
|
||||
OfflineKitPath = offlineKitPath,
|
||||
UseOfflineKit = true
|
||||
});
|
||||
var store = CreateStore(options);
|
||||
|
||||
// Act
|
||||
var roots = await store.GetFulcioRootsAsync();
|
||||
|
||||
// Assert
|
||||
roots.Should().HaveCount(1);
|
||||
roots[0].Subject.Should().Be("CN=Offline Kit Root");
|
||||
}
|
||||
|
||||
private FileSystemRootStore CreateStore(IOptions<OfflineRootStoreOptions> options)
|
||||
{
|
||||
return new FileSystemRootStore(_loggerMock.Object, options);
|
||||
}
|
||||
|
||||
private IOptions<OfflineRootStoreOptions> CreateOptions(
|
||||
string? fulcioPath = null,
|
||||
string? orgSigningPath = null,
|
||||
string? rekorPath = null)
|
||||
{
|
||||
return Options.Create(new OfflineRootStoreOptions
|
||||
{
|
||||
BaseRootPath = _testRootPath,
|
||||
FulcioBundlePath = fulcioPath,
|
||||
OrgSigningBundlePath = orgSigningPath,
|
||||
RekorBundlePath = rekorPath
|
||||
});
|
||||
}
|
||||
|
||||
private static X509Certificate2 CreateTestCertificate(string subject)
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var request = new CertificateRequest(
|
||||
subject,
|
||||
rsa,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
|
||||
// Add basic constraints for a CA certificate
|
||||
request.CertificateExtensions.Add(
|
||||
new X509BasicConstraintsExtension(true, false, 0, true));
|
||||
|
||||
// Add Subject Key Identifier
|
||||
request.CertificateExtensions.Add(
|
||||
new X509SubjectKeyIdentifierExtension(request.PublicKey, false));
|
||||
|
||||
var notBefore = DateTimeOffset.UtcNow.AddDays(-1);
|
||||
var notAfter = DateTimeOffset.UtcNow.AddYears(10);
|
||||
|
||||
return request.CreateSelfSigned(notBefore, notAfter);
|
||||
}
|
||||
|
||||
private static async Task WritePemFileAsync(string path, X509Certificate2 cert)
|
||||
{
|
||||
var pem = new StringBuilder();
|
||||
pem.AppendLine("-----BEGIN CERTIFICATE-----");
|
||||
pem.AppendLine(Convert.ToBase64String(cert.RawData, Base64FormattingOptions.InsertLineBreaks));
|
||||
pem.AppendLine("-----END CERTIFICATE-----");
|
||||
|
||||
await File.WriteAllTextAsync(path, pem.ToString());
|
||||
}
|
||||
|
||||
private static async Task WriteMultiplePemFileAsync(string path, X509Certificate2[] certs)
|
||||
{
|
||||
var pem = new StringBuilder();
|
||||
foreach (var cert in certs)
|
||||
{
|
||||
pem.AppendLine("-----BEGIN CERTIFICATE-----");
|
||||
pem.AppendLine(Convert.ToBase64String(cert.RawData, Base64FormattingOptions.InsertLineBreaks));
|
||||
pem.AppendLine("-----END CERTIFICATE-----");
|
||||
pem.AppendLine();
|
||||
}
|
||||
|
||||
await File.WriteAllTextAsync(path, pem.ToString());
|
||||
}
|
||||
|
||||
private static string ComputeThumbprint(X509Certificate2 cert)
|
||||
{
|
||||
var hash = SHA256.HashData(cert.RawData);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// OfflineCertChainValidatorTests.cs
|
||||
// Sprint: SPRINT_20251226_003_ATTESTOR_offline_verification
|
||||
// Task: 0022 - Unit tests for certificate chain validation
|
||||
// Description: Unit tests for offline certificate chain validation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
using StellaOps.Attestor.Offline.Abstractions;
|
||||
using StellaOps.Attestor.Offline.Models;
|
||||
using StellaOps.Attestor.Offline.Services;
|
||||
using StellaOps.Attestor.ProofChain.Merkle;
|
||||
|
||||
namespace StellaOps.Attestor.Offline.Tests;
|
||||
|
||||
public class OfflineCertChainValidatorTests
|
||||
{
|
||||
private readonly Mock<ILogger<OfflineVerifier>> _loggerMock;
|
||||
private readonly IMerkleTreeBuilder _merkleBuilder;
|
||||
private readonly IOptions<OfflineVerificationConfig> _config;
|
||||
|
||||
public OfflineCertChainValidatorTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<OfflineVerifier>>();
|
||||
_merkleBuilder = new DeterministicMerkleTreeBuilder();
|
||||
_config = Options.Create(new OfflineVerificationConfig());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestation_WithValidCertChain_ChainIsValid()
|
||||
{
|
||||
// Arrange
|
||||
var (rootCert, leafCert) = CreateCertificateChain();
|
||||
var attestation = CreateAttestationWithCertChain(leafCert, rootCert);
|
||||
|
||||
var rootStore = CreateRootStoreWithCerts(new[] { rootCert });
|
||||
var verifier = CreateVerifier(rootStore);
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: false,
|
||||
VerifySignatures: false,
|
||||
VerifyCertificateChain: true);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyAttestationAsync(attestation, options);
|
||||
|
||||
// Assert
|
||||
result.CertificateChainValid.Should().BeTrue();
|
||||
result.Issues.Should().NotContain(i => i.Code.Contains("CERT"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestation_WithUntrustedRoot_ChainIsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var (rootCert, leafCert) = CreateCertificateChain();
|
||||
var untrustedRoot = CreateSelfSignedCertificate("CN=Untrusted Root CA");
|
||||
var attestation = CreateAttestationWithCertChain(leafCert, rootCert);
|
||||
|
||||
// Root store has a different root
|
||||
var rootStore = CreateRootStoreWithCerts(new[] { untrustedRoot });
|
||||
var verifier = CreateVerifier(rootStore);
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: false,
|
||||
VerifySignatures: false,
|
||||
VerifyCertificateChain: true);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyAttestationAsync(attestation, options);
|
||||
|
||||
// Assert
|
||||
result.CertificateChainValid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Code.StartsWith("CERT"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestation_WithMissingCertChain_ReturnsIssue()
|
||||
{
|
||||
// Arrange
|
||||
var attestation = CreateAttestationWithoutCertChain();
|
||||
|
||||
var rootStore = CreateRootStoreWithCerts(Array.Empty<X509Certificate2>());
|
||||
var verifier = CreateVerifier(rootStore);
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: false,
|
||||
VerifySignatures: false,
|
||||
VerifyCertificateChain: true);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyAttestationAsync(attestation, options);
|
||||
|
||||
// Assert
|
||||
result.CertificateChainValid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Code.StartsWith("CERT") || i.Code.Contains("CHAIN"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestation_WithExpiredCert_ChainIsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var expiredCert = CreateExpiredCertificate("CN=Expired Leaf");
|
||||
var rootCert = CreateSelfSignedCertificate("CN=Test Root CA");
|
||||
var attestation = CreateAttestationWithCertChain(expiredCert, rootCert);
|
||||
|
||||
var rootStore = CreateRootStoreWithCerts(new[] { rootCert });
|
||||
var verifier = CreateVerifier(rootStore);
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: false,
|
||||
VerifySignatures: false,
|
||||
VerifyCertificateChain: true);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyAttestationAsync(attestation, options);
|
||||
|
||||
// Assert
|
||||
result.CertificateChainValid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Code.StartsWith("CERT"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestation_WithNotYetValidCert_ChainIsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var futureCert = CreateFutureCertificate("CN=Future Leaf");
|
||||
var rootCert = CreateSelfSignedCertificate("CN=Test Root CA");
|
||||
var attestation = CreateAttestationWithCertChain(futureCert, rootCert);
|
||||
|
||||
var rootStore = CreateRootStoreWithCerts(new[] { rootCert });
|
||||
var verifier = CreateVerifier(rootStore);
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: false,
|
||||
VerifySignatures: false,
|
||||
VerifyCertificateChain: true);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyAttestationAsync(attestation, options);
|
||||
|
||||
// Assert
|
||||
result.CertificateChainValid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Code.StartsWith("CERT"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundle_WithMultipleAttestations_ValidatesCertChainsForAll()
|
||||
{
|
||||
// Arrange
|
||||
var (rootCert, leafCert1) = CreateCertificateChain();
|
||||
|
||||
var attestation1 = CreateAttestationWithCertChain(leafCert1, rootCert, "entry-001");
|
||||
var attestation2 = CreateAttestationWithCertChain(leafCert1, rootCert, "entry-002");
|
||||
|
||||
var bundle = CreateBundleFromAttestations(new[] { attestation1, attestation2 });
|
||||
|
||||
var rootStore = CreateRootStoreWithCerts(new[] { rootCert });
|
||||
var verifier = CreateVerifier(rootStore);
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: true,
|
||||
VerifySignatures: false,
|
||||
VerifyCertificateChain: true);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyBundleAsync(bundle, options);
|
||||
|
||||
// Assert
|
||||
result.CertificateChainValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestation_CertChainValidationSkipped_WhenDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var attestation = CreateAttestationWithoutCertChain();
|
||||
|
||||
var rootStore = CreateRootStoreWithCerts(Array.Empty<X509Certificate2>());
|
||||
var verifier = CreateVerifier(rootStore);
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: false,
|
||||
VerifySignatures: false,
|
||||
VerifyCertificateChain: false); // Disabled
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyAttestationAsync(attestation, options);
|
||||
|
||||
// Assert - When cert chain validation is disabled, it should not report cert-related issues
|
||||
result.Issues.Should().NotContain(i => i.Code.Contains("CERT_CHAIN"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestation_WithSelfSignedLeaf_ChainIsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var selfSignedLeaf = CreateSelfSignedCertificate("CN=Self Signed Leaf");
|
||||
var rootCert = CreateSelfSignedCertificate("CN=Different Root CA");
|
||||
var attestation = CreateAttestationWithCertChain(selfSignedLeaf);
|
||||
|
||||
var rootStore = CreateRootStoreWithCerts(new[] { rootCert });
|
||||
var verifier = CreateVerifier(rootStore);
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: false,
|
||||
VerifySignatures: false,
|
||||
VerifyCertificateChain: true);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyAttestationAsync(attestation, options);
|
||||
|
||||
// Assert
|
||||
result.CertificateChainValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestation_WithEmptyRootStore_ChainIsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var (rootCert, leafCert) = CreateCertificateChain();
|
||||
var attestation = CreateAttestationWithCertChain(leafCert, rootCert);
|
||||
|
||||
var rootStore = CreateRootStoreWithCerts(Array.Empty<X509Certificate2>());
|
||||
var verifier = CreateVerifier(rootStore);
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: false,
|
||||
VerifySignatures: false,
|
||||
VerifyCertificateChain: true);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyAttestationAsync(attestation, options);
|
||||
|
||||
// Assert
|
||||
result.CertificateChainValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
private OfflineVerifier CreateVerifier(IOfflineRootStore rootStore)
|
||||
{
|
||||
return new OfflineVerifier(
|
||||
rootStore,
|
||||
_merkleBuilder,
|
||||
_loggerMock.Object,
|
||||
_config,
|
||||
null);
|
||||
}
|
||||
|
||||
private static IOfflineRootStore CreateRootStoreWithCerts(X509Certificate2[] certs)
|
||||
{
|
||||
var mock = new Mock<IOfflineRootStore>();
|
||||
mock.Setup(x => x.GetFulcioRootsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new X509Certificate2Collection(certs));
|
||||
mock.Setup(x => x.GetOrgSigningKeysAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new X509Certificate2Collection());
|
||||
mock.Setup(x => x.GetRekorKeysAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new X509Certificate2Collection());
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
private static (X509Certificate2 Root, X509Certificate2 Leaf) CreateCertificateChain()
|
||||
{
|
||||
using var rootKey = RSA.Create(2048);
|
||||
var rootRequest = new CertificateRequest(
|
||||
"CN=Test Fulcio Root CA",
|
||||
rootKey,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
|
||||
rootRequest.CertificateExtensions.Add(
|
||||
new X509BasicConstraintsExtension(true, true, 1, true));
|
||||
rootRequest.CertificateExtensions.Add(
|
||||
new X509KeyUsageExtension(
|
||||
X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true));
|
||||
|
||||
var rootCert = rootRequest.CreateSelfSigned(
|
||||
DateTimeOffset.UtcNow.AddDays(-30),
|
||||
DateTimeOffset.UtcNow.AddYears(10));
|
||||
|
||||
using var leafKey = RSA.Create(2048);
|
||||
var leafRequest = new CertificateRequest(
|
||||
"CN=Sigstore Signer",
|
||||
leafKey,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
|
||||
leafRequest.CertificateExtensions.Add(
|
||||
new X509BasicConstraintsExtension(false, false, 0, true));
|
||||
leafRequest.CertificateExtensions.Add(
|
||||
new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, true));
|
||||
|
||||
var leafCert = leafRequest.Create(
|
||||
rootCert,
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
DateTimeOffset.UtcNow.AddMinutes(10),
|
||||
Guid.NewGuid().ToByteArray());
|
||||
|
||||
return (rootCert, leafCert);
|
||||
}
|
||||
|
||||
private static X509Certificate2 CreateSelfSignedCertificate(string subject)
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var request = new CertificateRequest(
|
||||
subject,
|
||||
rsa,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
|
||||
request.CertificateExtensions.Add(
|
||||
new X509BasicConstraintsExtension(true, false, 0, true));
|
||||
|
||||
return request.CreateSelfSigned(
|
||||
DateTimeOffset.UtcNow.AddDays(-30),
|
||||
DateTimeOffset.UtcNow.AddYears(10));
|
||||
}
|
||||
|
||||
private static X509Certificate2 CreateExpiredCertificate(string subject)
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var request = new CertificateRequest(
|
||||
subject,
|
||||
rsa,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
|
||||
return request.CreateSelfSigned(
|
||||
DateTimeOffset.UtcNow.AddDays(-365),
|
||||
DateTimeOffset.UtcNow.AddDays(-1));
|
||||
}
|
||||
|
||||
private static X509Certificate2 CreateFutureCertificate(string subject)
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var request = new CertificateRequest(
|
||||
subject,
|
||||
rsa,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
|
||||
return request.CreateSelfSigned(
|
||||
DateTimeOffset.UtcNow.AddDays(1),
|
||||
DateTimeOffset.UtcNow.AddYears(1));
|
||||
}
|
||||
|
||||
private static BundledAttestation CreateAttestationWithCertChain(
|
||||
X509Certificate2 leafCert,
|
||||
X509Certificate2? rootCert = null,
|
||||
string entryId = "entry-001")
|
||||
{
|
||||
var certChain = new List<string> { ConvertToPem(leafCert) };
|
||||
if (rootCert != null)
|
||||
{
|
||||
certChain.Add(ConvertToPem(rootCert));
|
||||
}
|
||||
|
||||
return new BundledAttestation
|
||||
{
|
||||
EntryId = entryId,
|
||||
RekorUuid = Guid.NewGuid().ToString("N"),
|
||||
RekorLogIndex = 10000,
|
||||
ArtifactDigest = $"sha256:{entryId.PadRight(64, 'a')}",
|
||||
PredicateType = "verdict.stella/v1",
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
SigningMode = "keyless",
|
||||
SigningIdentity = new SigningIdentity
|
||||
{
|
||||
Issuer = "https://authority.internal",
|
||||
Subject = "signer@stella-ops.org",
|
||||
San = "urn:stellaops:signer"
|
||||
},
|
||||
InclusionProof = new RekorInclusionProof
|
||||
{
|
||||
Checkpoint = new CheckpointData
|
||||
{
|
||||
Origin = "rekor.sigstore.dev",
|
||||
Size = 100000,
|
||||
RootHash = Convert.ToBase64String(new byte[32]),
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
},
|
||||
Path = new List<string>
|
||||
{
|
||||
Convert.ToBase64String(new byte[32]),
|
||||
Convert.ToBase64String(new byte[32])
|
||||
}
|
||||
},
|
||||
Envelope = new DsseEnvelopeData
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = Convert.ToBase64String("{\"test\":true}"u8.ToArray()),
|
||||
Signatures = new List<EnvelopeSignature>
|
||||
{
|
||||
new() { KeyId = "key-1", Sig = Convert.ToBase64String(new byte[64]) }
|
||||
},
|
||||
CertificateChain = certChain
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static BundledAttestation CreateAttestationWithoutCertChain()
|
||||
{
|
||||
return new BundledAttestation
|
||||
{
|
||||
EntryId = "entry-no-chain",
|
||||
RekorUuid = Guid.NewGuid().ToString("N"),
|
||||
RekorLogIndex = 10000,
|
||||
ArtifactDigest = "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
|
||||
PredicateType = "verdict.stella/v1",
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
SigningMode = "keyless",
|
||||
SigningIdentity = new SigningIdentity
|
||||
{
|
||||
Issuer = "https://authority.internal",
|
||||
Subject = "signer@stella-ops.org",
|
||||
San = "urn:stellaops:signer"
|
||||
},
|
||||
InclusionProof = new RekorInclusionProof
|
||||
{
|
||||
Checkpoint = new CheckpointData
|
||||
{
|
||||
Origin = "rekor.sigstore.dev",
|
||||
Size = 100000,
|
||||
RootHash = Convert.ToBase64String(new byte[32]),
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
},
|
||||
Path = new List<string>()
|
||||
},
|
||||
Envelope = new DsseEnvelopeData
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = Convert.ToBase64String("{\"test\":true}"u8.ToArray()),
|
||||
Signatures = new List<EnvelopeSignature>
|
||||
{
|
||||
new() { KeyId = "key-1", Sig = Convert.ToBase64String(new byte[64]) }
|
||||
},
|
||||
CertificateChain = null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private AttestationBundle CreateBundleFromAttestations(BundledAttestation[] attestations)
|
||||
{
|
||||
var sortedAttestations = attestations
|
||||
.OrderBy(a => a.EntryId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var leafValues = sortedAttestations
|
||||
.Select(a => (ReadOnlyMemory<byte>)System.Text.Encoding.UTF8.GetBytes(a.EntryId))
|
||||
.ToList();
|
||||
|
||||
var merkleRoot = _merkleBuilder.ComputeMerkleRoot(leafValues);
|
||||
var merkleRootHex = $"sha256:{Convert.ToHexString(merkleRoot).ToLowerInvariant()}";
|
||||
|
||||
return new AttestationBundle
|
||||
{
|
||||
Metadata = new BundleMetadata
|
||||
{
|
||||
BundleId = merkleRootHex,
|
||||
Version = "1.0",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
PeriodStart = DateTimeOffset.UtcNow.AddDays(-30),
|
||||
PeriodEnd = DateTimeOffset.UtcNow,
|
||||
AttestationCount = attestations.Length
|
||||
},
|
||||
Attestations = attestations,
|
||||
MerkleTree = new MerkleTreeInfo
|
||||
{
|
||||
Algorithm = "SHA256",
|
||||
Root = merkleRootHex,
|
||||
LeafCount = attestations.Length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string ConvertToPem(X509Certificate2 cert)
|
||||
{
|
||||
var base64 = Convert.ToBase64String(cert.RawData);
|
||||
return $"-----BEGIN CERTIFICATE-----\n{base64}\n-----END CERTIFICATE-----";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// OfflineVerifierTests.cs
|
||||
// Sprint: SPRINT_20251226_003_ATTESTOR_offline_verification
|
||||
// Task: 0019-0022 - Unit tests for offline verification
|
||||
// Description: Unit tests for OfflineVerifier service
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Attestor.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
using StellaOps.Attestor.Offline.Abstractions;
|
||||
using StellaOps.Attestor.Offline.Models;
|
||||
using StellaOps.Attestor.Offline.Services;
|
||||
using StellaOps.Attestor.ProofChain.Merkle;
|
||||
|
||||
// Alias to resolve ambiguity
|
||||
using Severity = StellaOps.Attestor.Offline.Models.VerificationIssueSeverity;
|
||||
|
||||
namespace StellaOps.Attestor.Offline.Tests;
|
||||
|
||||
public class OfflineVerifierTests
|
||||
{
|
||||
private readonly Mock<IOfflineRootStore> _rootStoreMock;
|
||||
private readonly IMerkleTreeBuilder _merkleBuilder;
|
||||
private readonly Mock<IOrgKeySigner> _orgSignerMock;
|
||||
private readonly Mock<ILogger<OfflineVerifier>> _loggerMock;
|
||||
private readonly IOptions<OfflineVerificationConfig> _config;
|
||||
|
||||
public OfflineVerifierTests()
|
||||
{
|
||||
_rootStoreMock = new Mock<IOfflineRootStore>();
|
||||
_merkleBuilder = new DeterministicMerkleTreeBuilder();
|
||||
_orgSignerMock = new Mock<IOrgKeySigner>();
|
||||
_loggerMock = new Mock<ILogger<OfflineVerifier>>();
|
||||
_config = Options.Create(new OfflineVerificationConfig());
|
||||
|
||||
// Setup default root store behavior
|
||||
_rootStoreMock
|
||||
.Setup(x => x.GetFulcioRootsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new X509Certificate2Collection());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_ValidBundle_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateTestBundle(5);
|
||||
var verifier = CreateVerifier();
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: true,
|
||||
VerifySignatures: false, // Skip signature verification for this test
|
||||
VerifyCertificateChain: false,
|
||||
VerifyOrgSignature: false);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyBundleAsync(bundle, options);
|
||||
|
||||
// Assert
|
||||
result.Valid.Should().BeTrue();
|
||||
result.MerkleProofValid.Should().BeTrue();
|
||||
result.Issues.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_TamperedMerkleRoot_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateTestBundle(5);
|
||||
|
||||
// Tamper with the Merkle root
|
||||
var tamperedBundle = bundle with
|
||||
{
|
||||
MerkleTree = new MerkleTreeInfo
|
||||
{
|
||||
Algorithm = "SHA256",
|
||||
Root = "sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
LeafCount = 5
|
||||
}
|
||||
};
|
||||
|
||||
var verifier = CreateVerifier();
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: true,
|
||||
VerifySignatures: false,
|
||||
VerifyCertificateChain: false);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyBundleAsync(tamperedBundle, options);
|
||||
|
||||
// Assert
|
||||
result.Valid.Should().BeFalse();
|
||||
result.MerkleProofValid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Code == "MERKLE_ROOT_MISMATCH");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_MissingOrgSignature_WhenRequired_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateTestBundle(3);
|
||||
var verifier = CreateVerifier();
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: false,
|
||||
VerifySignatures: false,
|
||||
VerifyCertificateChain: false,
|
||||
VerifyOrgSignature: true,
|
||||
RequireOrgSignature: true);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyBundleAsync(bundle, options);
|
||||
|
||||
// Assert
|
||||
result.Valid.Should().BeFalse();
|
||||
result.OrgSignatureValid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Code == "ORG_SIG_MISSING");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_WithValidOrgSignature_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateTestBundle(3);
|
||||
var orgSignature = new OrgSignature
|
||||
{
|
||||
KeyId = "org-key-2025",
|
||||
Algorithm = "ECDSA_P256",
|
||||
Signature = Convert.ToBase64String(new byte[64]),
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
CertificateChain = null
|
||||
};
|
||||
|
||||
var signedBundle = bundle with { OrgSignature = orgSignature };
|
||||
|
||||
_orgSignerMock
|
||||
.Setup(x => x.VerifyBundleAsync(It.IsAny<byte[]>(), orgSignature, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var verifier = CreateVerifier();
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: true,
|
||||
VerifySignatures: false,
|
||||
VerifyCertificateChain: false,
|
||||
VerifyOrgSignature: true);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyBundleAsync(signedBundle, options);
|
||||
|
||||
// Assert
|
||||
result.Valid.Should().BeTrue();
|
||||
result.OrgSignatureValid.Should().BeTrue();
|
||||
result.OrgSignatureKeyId.Should().Be("org-key-2025");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_ValidAttestation_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var attestation = CreateTestAttestation("entry-001");
|
||||
var verifier = CreateVerifier();
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: false,
|
||||
VerifySignatures: true,
|
||||
VerifyCertificateChain: false);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyAttestationAsync(attestation, options);
|
||||
|
||||
// Assert
|
||||
result.Valid.Should().BeTrue();
|
||||
result.SignaturesValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_EmptySignature_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var attestation = CreateTestAttestation("entry-001");
|
||||
|
||||
// Remove signatures
|
||||
var tamperedAttestation = attestation with
|
||||
{
|
||||
Envelope = attestation.Envelope with
|
||||
{
|
||||
Signatures = new List<EnvelopeSignature>()
|
||||
}
|
||||
};
|
||||
|
||||
var verifier = CreateVerifier();
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: false,
|
||||
VerifySignatures: true,
|
||||
VerifyCertificateChain: false);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyAttestationAsync(tamperedAttestation, options);
|
||||
|
||||
// Assert
|
||||
result.Valid.Should().BeFalse();
|
||||
result.SignaturesValid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Code == "DSSE_NO_SIGNATURES");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVerificationSummariesAsync_ReturnsAllAttestations()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateTestBundle(10);
|
||||
var verifier = CreateVerifier();
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: false,
|
||||
VerifySignatures: true,
|
||||
VerifyCertificateChain: false);
|
||||
|
||||
// Act
|
||||
var summaries = await verifier.GetVerificationSummariesAsync(bundle, options);
|
||||
|
||||
// Assert
|
||||
summaries.Should().HaveCount(10);
|
||||
summaries.Should().OnlyContain(s => s.VerificationStatus == AttestationVerificationStatus.Valid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_StrictMode_FailsOnWarnings()
|
||||
{
|
||||
// Arrange
|
||||
var attestation = CreateTestAttestation("entry-001");
|
||||
|
||||
// Add inclusion proof with empty path to trigger warning
|
||||
var attestationWithEmptyProof = attestation with
|
||||
{
|
||||
InclusionProof = new RekorInclusionProof
|
||||
{
|
||||
Checkpoint = new CheckpointData
|
||||
{
|
||||
Origin = "rekor.sigstore.dev",
|
||||
Size = 100000,
|
||||
RootHash = Convert.ToBase64String(new byte[32]),
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
},
|
||||
Path = new List<string>() // Empty path triggers warning
|
||||
}
|
||||
};
|
||||
|
||||
var bundle = CreateTestBundleFromAttestations(new[] { attestationWithEmptyProof });
|
||||
var verifier = CreateVerifier();
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: true,
|
||||
VerifySignatures: true, // Needs to be true to check attestation-level proofs
|
||||
VerifyCertificateChain: false,
|
||||
StrictMode: true);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyBundleAsync(bundle, options);
|
||||
|
||||
// Assert
|
||||
result.Valid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Severity == Severity.Warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_DeterministicOrdering_SameMerkleValidation()
|
||||
{
|
||||
// Arrange
|
||||
var attestations = Enumerable.Range(0, 10)
|
||||
.Select(i => CreateTestAttestation($"entry-{i:D4}"))
|
||||
.ToArray();
|
||||
|
||||
// Create bundles with same attestations but different initial orders
|
||||
var bundle1 = CreateTestBundleFromAttestations(attestations.OrderBy(_ => Guid.NewGuid()).ToArray());
|
||||
var bundle2 = CreateTestBundleFromAttestations(attestations.OrderByDescending(a => a.EntryId).ToArray());
|
||||
|
||||
var verifier = CreateVerifier();
|
||||
|
||||
var options = new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: true,
|
||||
VerifySignatures: false,
|
||||
VerifyCertificateChain: false);
|
||||
|
||||
// Act
|
||||
var result1 = await verifier.VerifyBundleAsync(bundle1, options);
|
||||
var result2 = await verifier.VerifyBundleAsync(bundle2, options);
|
||||
|
||||
// Assert - both should have the same merkle validation result
|
||||
result1.MerkleProofValid.Should().Be(result2.MerkleProofValid);
|
||||
}
|
||||
|
||||
private OfflineVerifier CreateVerifier()
|
||||
{
|
||||
return new OfflineVerifier(
|
||||
_rootStoreMock.Object,
|
||||
_merkleBuilder,
|
||||
_loggerMock.Object,
|
||||
_config,
|
||||
_orgSignerMock.Object);
|
||||
}
|
||||
|
||||
private AttestationBundle CreateTestBundle(int attestationCount)
|
||||
{
|
||||
var attestations = Enumerable.Range(0, attestationCount)
|
||||
.Select(i => CreateTestAttestation($"entry-{i:D4}"))
|
||||
.ToList();
|
||||
|
||||
return CreateTestBundleFromAttestations(attestations.ToArray());
|
||||
}
|
||||
|
||||
private AttestationBundle CreateTestBundleFromAttestations(BundledAttestation[] attestations)
|
||||
{
|
||||
// Sort deterministically for Merkle tree
|
||||
var sortedAttestations = attestations
|
||||
.OrderBy(a => a.EntryId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// Compute Merkle root
|
||||
var leafValues = sortedAttestations
|
||||
.Select(a => (ReadOnlyMemory<byte>)System.Text.Encoding.UTF8.GetBytes(a.EntryId))
|
||||
.ToList();
|
||||
|
||||
var merkleRoot = _merkleBuilder.ComputeMerkleRoot(leafValues);
|
||||
var merkleRootHex = $"sha256:{Convert.ToHexString(merkleRoot).ToLowerInvariant()}";
|
||||
|
||||
return new AttestationBundle
|
||||
{
|
||||
Metadata = new BundleMetadata
|
||||
{
|
||||
BundleId = merkleRootHex,
|
||||
Version = "1.0",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
PeriodStart = DateTimeOffset.UtcNow.AddDays(-30),
|
||||
PeriodEnd = DateTimeOffset.UtcNow,
|
||||
AttestationCount = attestations.Length
|
||||
},
|
||||
Attestations = attestations,
|
||||
MerkleTree = new MerkleTreeInfo
|
||||
{
|
||||
Algorithm = "SHA256",
|
||||
Root = merkleRootHex,
|
||||
LeafCount = attestations.Length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static BundledAttestation CreateTestAttestation(string entryId)
|
||||
{
|
||||
return new BundledAttestation
|
||||
{
|
||||
EntryId = entryId,
|
||||
RekorUuid = Guid.NewGuid().ToString("N"),
|
||||
RekorLogIndex = 10000,
|
||||
ArtifactDigest = $"sha256:{entryId.PadRight(64, 'a')}",
|
||||
PredicateType = "verdict.stella/v1",
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
SigningMode = "keyless",
|
||||
SigningIdentity = new SigningIdentity
|
||||
{
|
||||
Issuer = "https://authority.internal",
|
||||
Subject = "signer@stella-ops.org",
|
||||
San = "urn:stellaops:signer"
|
||||
},
|
||||
InclusionProof = new RekorInclusionProof
|
||||
{
|
||||
Checkpoint = new CheckpointData
|
||||
{
|
||||
Origin = "rekor.sigstore.dev",
|
||||
Size = 100000,
|
||||
RootHash = Convert.ToBase64String(new byte[32]),
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
},
|
||||
Path = new List<string>
|
||||
{
|
||||
Convert.ToBase64String(new byte[32]),
|
||||
Convert.ToBase64String(new byte[32])
|
||||
}
|
||||
},
|
||||
Envelope = new DsseEnvelopeData
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = Convert.ToBase64String("{\"test\":true}"u8.ToArray()),
|
||||
Signatures = new List<EnvelopeSignature>
|
||||
{
|
||||
new() { KeyId = "key-1", Sig = Convert.ToBase64String(new byte[64]) }
|
||||
},
|
||||
CertificateChain = new List<string>
|
||||
{
|
||||
"-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----"
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>StellaOps.Attestor.Offline.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.0.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Offline\StellaOps.Attestor.Offline.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user