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:
StellaOps Bot
2025-12-26 15:17:15 +02:00
parent 7792749bb4
commit 907783f625
354 changed files with 79727 additions and 1346 deletions

View File

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

View File

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

View File

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

View File

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